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.sql.Types;
032import java.time.Duration;
033import java.time.ZoneId;
034import java.util.ArrayDeque;
035import java.util.ArrayList;
036import java.util.Arrays;
037import java.util.Collection;
038import java.util.Deque;
039import java.util.HashSet;
040import java.util.LinkedHashMap;
041import java.util.LinkedHashSet;
042import java.util.List;
043import java.util.Locale;
044import java.util.Map;
045import java.util.Optional;
046import java.util.Queue;
047import java.util.Set;
048import java.util.Spliterator;
049import java.util.Spliterators;
050import java.util.concurrent.atomic.AtomicReference;
051import java.util.concurrent.atomic.AtomicLong;
052import java.util.concurrent.locks.ReentrantLock;
053import java.util.function.Consumer;
054import java.util.function.Function;
055import java.util.function.UnaryOperator;
056import java.util.UUID;
057import java.util.logging.Level;
058import java.util.logging.Logger;
059import java.util.regex.Pattern;
060import java.util.stream.Collectors;
061import java.util.stream.Stream;
062import java.util.stream.StreamSupport;
063
064import static java.lang.String.format;
065import static java.lang.System.nanoTime;
066import static java.util.Objects.requireNonNull;
067
068/**
069 * Main class for performing database access operations.
070 *
071 * @author <a href="https://www.revetkn.com">Mark Allen</a>
072 * @since 1.0.0
073 */
074@ThreadSafe
075public final class Database {
076        @NonNull
077        private static final ThreadLocal<Deque<Transaction>> TRANSACTION_STACK_HOLDER;
078        private static final int DEFAULT_PARSED_SQL_CACHE_CAPACITY = 1024;
079        private static final int MAX_DIAGNOSTIC_MESSAGE_LENGTH = 1024;
080        private static final int MAX_DIAGNOSTIC_PARAMETER_LENGTH = 256;
081        private static final int MAX_DIAGNOSTIC_PARAMETERS_LENGTH = 2048;
082        private static final int MAX_DIAGNOSTIC_SQL_LENGTH = 2048;
083        @NonNull
084        private static final String TRUNCATED_SUFFIX = "... (truncated)";
085        @NonNull
086        private static final Pattern DIAGNOSTIC_WHITESPACE_PATTERN = Pattern.compile("\\s+");
087
088        static {
089                TRANSACTION_STACK_HOLDER = new ThreadLocal<>();
090        }
091
092        @NonNull
093        private final DataSource dataSource;
094        @NonNull
095        private final AtomicReference<@Nullable DatabaseType> databaseType;
096        @NonNull
097        private final AtomicReference<@Nullable DatabaseDialect> databaseDialect;
098        @NonNull
099        private final ThreadLocal<Connection> databaseTypeDetectionConnectionHolder;
100        @NonNull
101        private final ZoneId timeZone;
102        @NonNull
103        private final AmbiguousTimestampBindingStrategy ambiguousTimestampBindingStrategy;
104        @NonNull
105        private final InstanceProvider instanceProvider;
106        @NonNull
107        private final PreparedStatementBinder preparedStatementBinder;
108        @NonNull
109        private final ResultSetMapper resultSetMapper;
110        @NonNull
111        private final StatementLogger statementLogger;
112        @NonNull
113        private final ParameterRedactor parameterRedactor;
114        @NonNull
115        private final MetricsCollectorDispatcher metricsCollectorDispatcher;
116        @Nullable
117        private final Duration queryTimeout;
118        @Nullable
119        private final Integer fetchSize;
120        @Nullable
121        private final Integer maxRows;
122        @Nullable
123        private final Map<String, ParsedSqlVariants> parsedSqlCache;
124        @NonNull
125        private final AtomicLong defaultIdGenerator;
126        @NonNull
127        private final Logger logger;
128
129        @NonNull
130        private volatile DatabaseOperationSupportStatus executeLargeBatchSupported;
131        @NonNull
132        private volatile DatabaseOperationSupportStatus executeLargeUpdateSupported;
133
134        private Database(@NonNull Builder builder) {
135                requireNonNull(builder);
136
137                this.dataSource = requireNonNull(builder.dataSource);
138                this.databaseType = new AtomicReference<>(builder.databaseType);
139                this.databaseDialect = new AtomicReference<>(builder.databaseType == null ? null : builder.databaseType.dialect());
140                this.databaseTypeDetectionConnectionHolder = new ThreadLocal<>();
141                this.timeZone = builder.timeZone == null ? ZoneId.systemDefault() : builder.timeZone;
142                this.ambiguousTimestampBindingStrategy = builder.ambiguousTimestampBindingStrategy == null
143                                ? AmbiguousTimestampBindingStrategy.TIMESTAMP_WITH_TIME_ZONE
144                                : builder.ambiguousTimestampBindingStrategy;
145                this.instanceProvider = builder.instanceProvider == null ? new InstanceProvider() {} : builder.instanceProvider;
146                this.preparedStatementBinder = builder.preparedStatementBinder == null ? PreparedStatementBinder.withDefaultConfiguration() : builder.preparedStatementBinder;
147                this.resultSetMapper = builder.resultSetMapper == null ? ResultSetMapper.withDefaultConfiguration() : builder.resultSetMapper;
148                this.statementLogger = builder.statementLogger == null ? (statementLog) -> {} : builder.statementLogger;
149                this.parameterRedactor = builder.parameterRedactor == null ? ParameterRedactor.none() : builder.parameterRedactor;
150                this.metricsCollectorDispatcher = new MetricsCollectorDispatcher(builder.metricsCollector);
151                this.queryTimeout = validateQueryTimeout(builder.queryTimeout);
152                this.fetchSize = validateNonNegativeStatementSetting("fetchSize", builder.fetchSize);
153                this.maxRows = validateNonNegativeStatementSetting("maxRows", builder.maxRows);
154                if (builder.parsedSqlCacheCapacity != null && builder.parsedSqlCacheCapacity < 0)
155                        throw new IllegalArgumentException("parsedSqlCacheCapacity must be >= 0");
156
157                int parsedSqlCacheCapacity = builder.parsedSqlCacheCapacity == null
158                                ? DEFAULT_PARSED_SQL_CACHE_CAPACITY
159                                : builder.parsedSqlCacheCapacity;
160                this.parsedSqlCache = parsedSqlCacheCapacity == 0 ? null : new ConcurrentLruMap<>(parsedSqlCacheCapacity);
161                this.defaultIdGenerator = new AtomicLong();
162                this.logger = Logger.getLogger(getClass().getName());
163                this.executeLargeBatchSupported = DatabaseOperationSupportStatus.UNKNOWN;
164                this.executeLargeUpdateSupported = DatabaseOperationSupportStatus.UNKNOWN;
165        }
166
167        /**
168         * Provides a {@link Database} builder for the given {@link DataSource}.
169         *
170         * @param dataSource data source used to create the {@link Database} builder
171         * @return a {@link Database} builder
172         */
173        @NonNull
174        public static Builder withDataSource(@NonNull DataSource dataSource) {
175                requireNonNull(dataSource);
176                return new Builder(dataSource);
177        }
178
179        /**
180         * Gets a reference to the current transaction, if any.
181         *
182         * @return the current transaction
183         */
184        @NonNull
185        public Optional<Transaction> currentTransaction() {
186                @Nullable Deque<Transaction> transactionStack = TRANSACTION_STACK_HOLDER.get();
187                Transaction transaction = transactionStack == null || transactionStack.isEmpty() ? null : transactionStack.peek();
188
189                if (transaction == null || !isTransactionOwnedByThisDatabase(transaction))
190                        return Optional.empty();
191
192                return Optional.of(transaction);
193        }
194
195        @NonNull
196        private Optional<Transaction> currentTransactionForDatabaseOperation() {
197                @Nullable Deque<Transaction> transactionStack = TRANSACTION_STACK_HOLDER.get();
198                Transaction transaction = transactionStack == null || transactionStack.isEmpty() ? null : transactionStack.peek();
199
200                if (transaction == null)
201                        return Optional.empty();
202
203                if (!isTransactionOwnedByThisDatabase(transaction))
204                        throw wrongDatabaseTransactionException(transaction);
205
206                return Optional.of(transaction);
207        }
208
209        static boolean hasAmbientTransaction() {
210                Deque<Transaction> transactionStack = TRANSACTION_STACK_HOLDER.get();
211                return transactionStack != null && !transactionStack.isEmpty();
212        }
213
214        @NonNull
215        private Deque<Transaction> transactionStackForPush() {
216                Deque<Transaction> transactionStack = TRANSACTION_STACK_HOLDER.get();
217
218                if (transactionStack == null) {
219                        transactionStack = new ArrayDeque<>();
220                        TRANSACTION_STACK_HOLDER.set(transactionStack);
221                }
222
223                return transactionStack;
224        }
225
226        private boolean isTransactionOwnedByThisDatabase(@NonNull Transaction transaction) {
227                requireNonNull(transaction);
228                return transaction.isOwnedBy(getDataSource());
229        }
230
231        @NonNull
232        private DatabaseException wrongDatabaseTransactionException(@NonNull Transaction transaction) {
233                requireNonNull(transaction);
234                return new DatabaseException(format("Transaction %s belongs to a different %s than this %s. "
235                                                + "Use the %s instance that created the transaction, or explicitly participate only with a transaction "
236                                                + "created from the same %s.",
237                                transaction.id(), DataSource.class.getSimpleName(), Database.class.getSimpleName(),
238                                Database.class.getSimpleName(), DataSource.class.getSimpleName()));
239        }
240
241        /**
242         * Performs an operation transactionally.
243         * <p>
244         * The transaction will be automatically rolled back if an exception bubbles out of {@code transactionalOperation}.
245         * <p>
246         * Nested calls to {@code transaction(...)} are independent transactions with independent JDBC connections; they do
247         * not automatically join an outer transaction. Use {@link #participate(Transaction, TransactionalOperation)} to join an
248         * existing transaction explicitly. A transaction is scoped to the {@link DataSource} instance that created it; a
249         * {@link Database} using a different {@link DataSource} fails fast instead of joining it.
250         *
251         * @param transactionalOperation the operation to perform transactionally
252         */
253        public void transaction(@NonNull TransactionalOperation transactionalOperation) {
254                requireNonNull(transactionalOperation);
255
256                transaction(() -> {
257                        transactionalOperation.perform();
258                        return Optional.empty();
259                });
260        }
261
262        /**
263         * Performs an operation transactionally with the given options.
264         * <p>
265         * The transaction will be automatically rolled back if an exception bubbles out of {@code transactionalOperation}.
266         * <p>
267         * Nested calls to {@code transaction(...)} are independent transactions with independent JDBC connections; they do
268         * not automatically join an outer transaction. Use {@link #participate(Transaction, TransactionalOperation)} to join an
269         * existing transaction explicitly. A transaction is scoped to the {@link DataSource} instance that created it; a
270         * {@link Database} using a different {@link DataSource} fails fast instead of joining it.
271         *
272         * @param transactionOptions     options to apply to the transaction
273         * @param transactionalOperation the operation to perform transactionally
274         * @since 4.2.0
275         */
276        public void transaction(@NonNull TransactionOptions transactionOptions,
277                                                                                                        @NonNull TransactionalOperation transactionalOperation) {
278                requireNonNull(transactionOptions);
279                requireNonNull(transactionalOperation);
280
281                transaction(transactionOptions, () -> {
282                        transactionalOperation.perform();
283                        return Optional.empty();
284                });
285        }
286
287        /**
288         * Performs an operation transactionally and optionally returns a value.
289         * <p>
290         * The transaction will be automatically rolled back if an exception bubbles out of {@code transactionalOperation}.
291         * <p>
292         * Nested calls to {@code transaction(...)} are independent transactions with independent JDBC connections; they do
293         * not automatically join an outer transaction. Use {@link #participate(Transaction, ReturningTransactionalOperation)} to
294         * join an existing transaction explicitly. A transaction is scoped to the {@link DataSource} instance that created it; a
295         * {@link Database} using a different {@link DataSource} fails fast instead of joining it.
296         *
297         * @param transactionalOperation the operation to perform transactionally
298         * @param <T>                    the type to be returned
299         * @return the result of the transactional operation
300         */
301        @NonNull
302        public <T> Optional<T> transaction(@NonNull ReturningTransactionalOperation<T> transactionalOperation) {
303                requireNonNull(transactionalOperation);
304                return transaction(TransactionOptions.DEFAULT, transactionalOperation);
305        }
306
307        /**
308         * Performs an operation transactionally with the given options, optionally returning a value.
309         * <p>
310         * The transaction will be automatically rolled back if an exception bubbles out of {@code transactionalOperation}.
311         * <p>
312         * Nested calls to {@code transaction(...)} are independent transactions with independent JDBC connections; they do
313         * not automatically join an outer transaction. Use {@link #participate(Transaction, ReturningTransactionalOperation)} to
314         * join an existing transaction explicitly. A transaction is scoped to the {@link DataSource} instance that created it; a
315         * {@link Database} using a different {@link DataSource} fails fast instead of joining it.
316         *
317         * @param transactionOptions     options to apply to the transaction
318         * @param transactionalOperation the operation to perform transactionally
319         * @param <T>                    the type to be returned
320         * @return the result of the transactional operation
321         * @since 4.2.0
322         */
323        @NonNull
324        public <T> Optional<T> transaction(@NonNull TransactionOptions transactionOptions,
325                                                                                                                                                 @NonNull ReturningTransactionalOperation<T> transactionalOperation) {
326                requireNonNull(transactionOptions);
327                requireNonNull(transactionalOperation);
328
329                Transaction transaction = new Transaction(dataSource, transactionOptions, getMetricsCollectorDispatcher(),
330                                peekDatabaseType(), this::getDatabaseType);
331                Deque<Transaction> transactionStack = transactionStackForPush();
332                transactionStack.push(transaction);
333                boolean committed = false;
334                boolean commitFailed = false;
335                boolean rollbackFailed = false;
336                boolean rollbackAttempted = false;
337                Throwable thrown = null;
338                long transactionStartTime = nanoTime();
339                getMetricsCollectorDispatcher().didEnterTransactionClosure(transaction, transactionOptions.getIsolation(), transaction.getDatabaseType());
340
341                try {
342                        Optional<T> returnValue = transactionalOperation.perform();
343
344                        // Safeguard in case user code accidentally returns null instead of Optional.empty()
345                        if (returnValue == null)
346                                returnValue = Optional.empty();
347
348                        transaction.getConnectionLock().lock();
349
350                        try {
351                                if (transaction.isRollbackOnly()) {
352                                        rollbackAttempted = true;
353                                        transaction.rollback();
354                                } else {
355                                        try {
356                                                transaction.commit();
357                                        } catch (RuntimeException | Error e) {
358                                                commitFailed = true;
359                                                throw e;
360                                        }
361                                        committed = true;
362                                }
363
364                                transaction.markCompleted();
365                        } finally {
366                                transaction.getConnectionLock().unlock();
367                        }
368
369                        return returnValue;
370                } catch (RuntimeException e) {
371                        thrown = e;
372                        if (rollbackAttempted) {
373                                rollbackFailed = true;
374                                markTransactionCompleted(transaction);
375                        } else {
376                                rollbackFailed = rollbackTransactionAfterFailure(transaction, e);
377                        }
378
379                        restoreInterruptIfNeeded(e);
380                        throw e;
381                } catch (Error e) {
382                        thrown = e;
383                        if (rollbackAttempted) {
384                                rollbackFailed = true;
385                                markTransactionCompleted(transaction);
386                        } else {
387                                rollbackFailed = rollbackTransactionAfterFailure(transaction, e);
388                        }
389
390                        restoreInterruptIfNeeded(e);
391                        throw e;
392                } catch (Throwable t) {
393                        RuntimeException wrapped = new RuntimeException(t);
394                        thrown = wrapped;
395                        if (rollbackAttempted) {
396                                rollbackFailed = true;
397                                markTransactionCompleted(transaction);
398                        } else {
399                                rollbackFailed = rollbackTransactionAfterFailure(transaction, wrapped);
400                        }
401
402                        restoreInterruptIfNeeded(t);
403                        throw wrapped;
404                } finally {
405                        transactionStack.pop();
406
407                        // Ensure txn stack is fully cleaned up
408                        if (transactionStack.isEmpty())
409                                TRANSACTION_STACK_HOLDER.remove();
410
411                        Throwable cleanupFailure = null;
412                        boolean hadPhysicalTransaction = false;
413                        boolean completionFailed = commitFailed || rollbackFailed;
414                        boolean commitSerializationFailureRolledBack = commitFailed && !rollbackFailed
415                                        && transaction.didCommitFailWithSerializationFailure();
416                        boolean transactionOutcomeIndeterminate = completionFailed && !commitSerializationFailureRolledBack;
417
418                        try {
419                                transaction.getConnectionLock().lock();
420
421                                try {
422                                        hadPhysicalTransaction = transaction.hasConnection();
423
424                                        if (!transaction.isCompleted())
425                                                transaction.markCompleted();
426
427                                        cleanupFailure = cleanupCompletedTransactionConnection(transaction, cleanupFailure, completionFailed);
428                                } finally {
429                                        transaction.getConnectionLock().unlock();
430                                }
431                        } finally {
432                                if (transactionOutcomeIndeterminate && thrown instanceof DatabaseException)
433                                        ((DatabaseException) thrown).markTransactionOutcomeIndeterminate();
434
435                                // Execute any user-supplied post-execution hooks
436                                for (Consumer<TransactionResult> postTransactionOperation : transaction.getPostTransactionOperations()) {
437                                        long postTransactionStartTime = nanoTime();
438                                        Throwable postTransactionThrowable = null;
439                                        TransactionResult transactionResult = transactionResult(committed, transactionOutcomeIndeterminate);
440                                        try {
441                                                postTransactionOperation.accept(transactionResult);
442                                        } catch (Throwable cleanupException) {
443                                                postTransactionThrowable = cleanupException;
444                                                PostTransactionOperationException postTransactionOperationException =
445                                                                new PostTransactionOperationException(transactionResult, cleanupException);
446
447                                                if (cleanupFailure == null)
448                                                        cleanupFailure = postTransactionOperationException;
449                                                else
450                                                        cleanupFailure.addSuppressed(postTransactionOperationException);
451                                        } finally {
452                                                getMetricsCollectorDispatcher().didRunPostTransactionOperation(transaction, transactionResult, transaction.getDatabaseType(),
453                                                                Duration.ofNanos(nanoTime() - postTransactionStartTime), postTransactionThrowable);
454                                        }
455                                }
456                        }
457
458                        Throwable exitThrown = thrown == null ? cleanupFailure : thrown;
459                        getMetricsCollectorDispatcher().didExitTransactionClosure(transaction,
460                                        transactionClosureOutcome(committed, hadPhysicalTransaction, transactionOutcomeIndeterminate),
461                                        transaction.getDatabaseType(), Duration.ofNanos(nanoTime() - transactionStartTime), exitThrown);
462
463                        if (cleanupFailure != null) {
464                                if (thrown != null) {
465                                        if (thrown != cleanupFailure)
466                                                thrown.addSuppressed(cleanupFailure);
467                                } else if (cleanupFailure instanceof RuntimeException) {
468                                        if (committed && cleanupFailure instanceof DatabaseException)
469                                                ((DatabaseException) cleanupFailure).markTransactionOutcomeCommitted();
470                                        throw (RuntimeException) cleanupFailure;
471                                } else if (cleanupFailure instanceof Error) {
472                                        throw (Error) cleanupFailure;
473                                } else {
474                                        throw new RuntimeException(cleanupFailure);
475                                }
476                        }
477                }
478        }
479
480        /**
481         * Performs an operation transactionally, retrying according to the given retry policy.
482         * <p>
483         * The entire transaction closure may run more than once. Keep non-idempotent external side effects outside the closure
484         * unless they are safe to repeat.
485         * <p>
486         * Pyranid consults the retry policy only after the database outcome is known to be rolled back. This includes a recognized
487         * serialization failure reported by physical commit when the follow-up rollback succeeds. Other commit failures and all
488         * rollback failures are terminal because their outcome is indeterminate.
489         * <p>
490         * Unlike {@link #transaction(TransactionalOperation)} and related transaction methods, retrying methods return
491         * {@link TransactionRetryResult} so callers can inspect failures that were recovered before success.
492         * <p>
493         * This method fails fast if called inside an active transaction for this {@code Database}. Retrying a nested unit cannot
494         * restart the outer transaction safely.
495         *
496         * @param retryPolicy           retry policy to apply
497         * @param transactionalOperation the operation to perform transactionally
498         * @return retry result containing any failures retried before success
499         * @since 4.4.0
500         */
501        @NonNull
502        public TransactionRetryResult<Void> transactionWithRetry(@NonNull RetryPolicy retryPolicy,
503                                                                                                                                                                                                                                        @NonNull TransactionalOperation transactionalOperation) {
504                requireNonNull(retryPolicy);
505                requireNonNull(transactionalOperation);
506
507                return transactionWithRetry(retryPolicy, () -> {
508                        transactionalOperation.perform();
509                        return Optional.<Void>empty();
510                });
511        }
512
513        /**
514         * Performs an operation transactionally with the given options, retrying according to the given retry policy.
515         * <p>
516         * The entire transaction closure may run more than once. Keep non-idempotent external side effects outside the closure
517         * unless they are safe to repeat.
518         * <p>
519         * Pyranid consults the retry policy only after the database outcome is known to be rolled back. This includes a recognized
520         * serialization failure reported by physical commit when the follow-up rollback succeeds. Other commit failures and all
521         * rollback failures are terminal because their outcome is indeterminate.
522         * <p>
523         * Unlike {@link #transaction(TransactionOptions, TransactionalOperation)} and related transaction methods, retrying
524         * methods return {@link TransactionRetryResult} so callers can inspect failures that were recovered before success.
525         * <p>
526         * This method fails fast if called inside an active transaction for this {@code Database}. Retrying a nested unit cannot
527         * restart the outer transaction safely.
528         *
529         * @param retryPolicy           retry policy to apply
530         * @param transactionOptions     options to apply to each transaction attempt
531         * @param transactionalOperation the operation to perform transactionally
532         * @return retry result containing any failures retried before success
533         * @since 4.4.0
534         */
535        @NonNull
536        public TransactionRetryResult<Void> transactionWithRetry(@NonNull RetryPolicy retryPolicy,
537                                                                                                                                                                                                                                        @NonNull TransactionOptions transactionOptions,
538                                                                                                                                                                                                                                        @NonNull TransactionalOperation transactionalOperation) {
539                requireNonNull(retryPolicy);
540                requireNonNull(transactionOptions);
541                requireNonNull(transactionalOperation);
542
543                return transactionWithRetry(retryPolicy, transactionOptions, () -> {
544                        transactionalOperation.perform();
545                        return Optional.<Void>empty();
546                });
547        }
548
549        /**
550         * Performs an operation transactionally and optionally returns a value, retrying according to the given retry policy.
551         * <p>
552         * The entire transaction closure may run more than once. Keep non-idempotent external side effects outside the closure
553         * unless they are safe to repeat.
554         * <p>
555         * Pyranid consults the retry policy only after the database outcome is known to be rolled back. This includes a recognized
556         * serialization failure reported by physical commit when the follow-up rollback succeeds. Other commit failures and all
557         * rollback failures are terminal because their outcome is indeterminate.
558         * <p>
559         * Unlike {@link #transaction(ReturningTransactionalOperation)} and related transaction methods, retrying methods return
560         * {@link TransactionRetryResult} so callers can inspect failures that were recovered before success.
561         * <p>
562         * This method fails fast if called inside an active transaction for this {@code Database}. Retrying a nested unit cannot
563         * restart the outer transaction safely.
564         *
565         * @param retryPolicy           retry policy to apply
566         * @param transactionalOperation the operation to perform transactionally
567         * @param <T>                    the type to be returned
568         * @return retry result containing the successful transaction value and any failures retried before success
569         * @since 4.4.0
570         */
571        @NonNull
572        public <T> TransactionRetryResult<T> transactionWithRetry(@NonNull RetryPolicy retryPolicy,
573                                                                                                                                                                                                                                         @NonNull ReturningTransactionalOperation<T> transactionalOperation) {
574                requireNonNull(retryPolicy);
575                requireNonNull(transactionalOperation);
576
577                return transactionWithRetry(retryPolicy, TransactionOptions.DEFAULT, transactionalOperation);
578        }
579
580        /**
581         * Performs an operation transactionally with the given options and optionally returns a value, retrying according to the
582         * given retry policy.
583         * <p>
584         * The entire transaction closure may run more than once. Keep non-idempotent external side effects outside the closure
585         * unless they are safe to repeat.
586         * <p>
587         * Pyranid consults the retry policy only after the database outcome is known to be rolled back. This includes a recognized
588         * serialization failure reported by physical commit when the follow-up rollback succeeds. Other commit failures and all
589         * rollback failures are terminal because their outcome is indeterminate.
590         * <p>
591         * Unlike {@link #transaction(TransactionOptions, ReturningTransactionalOperation)} and related transaction methods,
592         * retrying methods return {@link TransactionRetryResult} so callers can inspect failures that were recovered before
593         * success.
594         * <p>
595         * This method fails fast if called inside an active transaction for this {@code Database}. Retrying a nested unit cannot
596         * restart the outer transaction safely.
597         *
598         * @param retryPolicy           retry policy to apply
599         * @param transactionOptions     options to apply to each transaction attempt
600         * @param transactionalOperation the operation to perform transactionally
601         * @param <T>                    the type to be returned
602         * @return retry result containing the successful transaction value and any failures retried before success
603         * @since 4.4.0
604         */
605        @NonNull
606        public <T> TransactionRetryResult<T> transactionWithRetry(@NonNull RetryPolicy retryPolicy,
607                                                                                                                                                                                                                                         @NonNull TransactionOptions transactionOptions,
608                                                                                                                                                                                                                                         @NonNull ReturningTransactionalOperation<T> transactionalOperation) {
609                requireNonNull(retryPolicy);
610                requireNonNull(transactionOptions);
611                requireNonNull(transactionalOperation);
612
613                if (currentTransaction().isPresent())
614                        throw new IllegalStateException("transactionWithRetry must not be called within an existing transaction");
615
616                List<DatabaseException> priorFailures = new ArrayList<>();
617
618                for (int attempt = 1; attempt <= retryPolicy.getMaxAttempts(); ++attempt) {
619                        try {
620                                return TransactionRetryResult.of(transaction(transactionOptions, transactionalOperation), priorFailures);
621                        } catch (DatabaseException e) {
622                                if (e.isTransactionOutcomeRetryUnsafe()) {
623                                        suppressPriorFailures(e, priorFailures);
624                                        throw e;
625                                }
626
627                                boolean finalAttempt = attempt == retryPolicy.getMaxAttempts();
628
629                                if (finalAttempt) {
630                                        suppressPriorFailures(e, priorFailures);
631                                        throw e;
632                                }
633
634                                Boolean retryable;
635
636                                try {
637                                        retryable = retryPolicy.getCondition().shouldRetry(e);
638                                } catch (RuntimeException | Error conditionFailure) {
639                                        suppressRetryFailures(conditionFailure, e, priorFailures);
640                                        throw conditionFailure;
641                                }
642
643                                if (retryable == null) {
644                                        NullPointerException nullConditionFailure =
645                                                        new NullPointerException("RetryPolicy.Condition returned null");
646                                        suppressRetryFailures(nullConditionFailure, e, priorFailures);
647                                        throw nullConditionFailure;
648                                }
649
650                                if (!retryable) {
651                                        suppressPriorFailures(e, priorFailures);
652                                        throw e;
653                                }
654
655                                priorFailures.add(e);
656
657                                Duration delay;
658
659                                try {
660                                        delay = retryPolicy.getBackoff().delayAfterFailedAttempt(attempt, e);
661                                } catch (RuntimeException | Error backoffFailure) {
662                                        suppressPriorFailures(backoffFailure, priorFailures);
663                                        throw backoffFailure;
664                                }
665
666                                if (delay == null) {
667                                        NullPointerException nullBackoffFailure = new NullPointerException("RetryPolicy.Backoff returned null");
668                                        suppressPriorFailures(nullBackoffFailure, priorFailures);
669                                        throw nullBackoffFailure;
670                                }
671
672                                if (delay.isNegative()) {
673                                        IllegalArgumentException negativeBackoffFailure =
674                                                        new IllegalArgumentException("RetryPolicy.Backoff returned a negative delay");
675                                        suppressPriorFailures(negativeBackoffFailure, priorFailures);
676                                        throw negativeBackoffFailure;
677                                }
678
679                                try {
680                                        sleepBackoff(delay);
681                                } catch (InterruptedException interruptedException) {
682                                        Thread.currentThread().interrupt();
683                                        suppressPriorFailures(e, priorFailures);
684                                        e.addSuppressed(interruptedException);
685                                        throw e;
686                                }
687                        }
688                }
689
690                throw new AssertionError("unreachable");
691        }
692
693        private void sleepBackoff(@NonNull Duration delay) throws InterruptedException {
694                requireNonNull(delay);
695
696                if (delay.isZero())
697                        return;
698
699                long millis;
700                int nanos;
701
702                try {
703                        millis = delay.toMillis();
704                        Duration remainder = delay.minusMillis(millis);
705                        nanos = (int) Math.min(999_999L, Math.max(0L, remainder.toNanos()));
706                } catch (ArithmeticException e) {
707                        millis = Long.MAX_VALUE;
708                        nanos = 999_999;
709                }
710
711                Thread.sleep(millis, nanos);
712        }
713
714        private void suppressRetryFailures(@NonNull Throwable failure,
715                                                                                                                                                 @NonNull DatabaseException currentFailure,
716                                                                                                                                                 @NonNull List<@NonNull DatabaseException> priorFailures) {
717                requireNonNull(failure);
718                requireNonNull(currentFailure);
719                requireNonNull(priorFailures);
720
721                suppressPriorFailures(failure, priorFailures);
722                suppressIfDifferent(failure, currentFailure);
723        }
724
725        private void suppressPriorFailures(@NonNull Throwable failure,
726                                                                                                                                                 @NonNull List<@NonNull DatabaseException> priorFailures) {
727                requireNonNull(failure);
728                requireNonNull(priorFailures);
729
730                for (DatabaseException priorFailure : priorFailures)
731                        suppressIfDifferent(failure, priorFailure);
732        }
733
734        private void suppressIfDifferent(@NonNull Throwable failure,
735                                                                                                                                         @NonNull Throwable suppressed) {
736                requireNonNull(failure);
737                requireNonNull(suppressed);
738
739                if (failure != suppressed)
740                        failure.addSuppressed(suppressed);
741        }
742
743        private boolean rollbackTransactionAfterFailure(@NonNull Transaction transaction,
744                                                                                                                                                                                                 @NonNull Throwable primary) {
745                requireNonNull(transaction);
746                requireNonNull(primary);
747
748                boolean rollbackFailed = false;
749                transaction.getConnectionLock().lock();
750
751                try {
752                        try {
753                                transaction.rollback();
754                        } catch (Throwable rollbackException) {
755                                rollbackFailed = true;
756                                if (primary != rollbackException)
757                                        primary.addSuppressed(rollbackException);
758                        } finally {
759                                transaction.markCompleted();
760                        }
761                } finally {
762                        transaction.getConnectionLock().unlock();
763                }
764
765                return rollbackFailed;
766        }
767
768        private void markTransactionCompleted(@NonNull Transaction transaction) {
769                requireNonNull(transaction);
770                transaction.getConnectionLock().lock();
771
772                try {
773                        transaction.markCompleted();
774                } finally {
775                        transaction.getConnectionLock().unlock();
776                }
777        }
778
779        @Nullable
780        private Throwable cleanupCompletedTransactionConnection(@NonNull Transaction transaction,
781                                                                                                                                                                                         @Nullable Throwable cleanupFailure,
782                                                                                                                                                                                         boolean completionFailed) {
783                requireNonNull(transaction);
784                boolean abortRequired = completionFailed;
785
786                // A failed commit or rollback makes the connection unsafe to restore and reuse, even when a recognized serialization
787                // failure plus successful rollback proves the logical outcome. When the outcome is unknown, restoring auto-commit to
788                // true is especially dangerous because it can commit work that Pyranid meant to roll back.
789                if (!abortRequired) {
790                        try {
791                                transaction.restoreTransactionIsolationIfNeeded();
792                        } catch (Throwable cleanupException) {
793                                cleanupFailure = appendSuppressed(cleanupFailure, cleanupException);
794                                abortRequired = true;
795                        }
796
797                        if (!abortRequired) {
798                                try {
799                                        transaction.restoreReadOnlyIfNeeded();
800                                } catch (Throwable cleanupException) {
801                                        cleanupFailure = appendSuppressed(cleanupFailure, cleanupException);
802                                        abortRequired = true;
803                                }
804                        }
805
806                        if (!abortRequired && transaction.getInitialAutoCommit().isPresent() && transaction.getInitialAutoCommit().get()) {
807                                try {
808                                        // Autocommit was true initially, so restoring to true now that transaction has completed
809                                        transaction.setAutoCommit(true);
810                                } catch (Throwable cleanupException) {
811                                        cleanupFailure = appendSuppressed(cleanupFailure, cleanupException);
812                                        abortRequired = true;
813                                }
814                        }
815                }
816
817                Connection connection = transaction.getExistingConnection().orElse(null);
818
819                if (connection != null) {
820                        Duration heldDuration = transaction.getConnectionAcquiredAtNanos()
821                                        .map(acquiredAtNanos -> Duration.ofNanos(nanoTime() - acquiredAtNanos))
822                                        .orElse(Duration.ZERO);
823
824                        if (abortRequired) {
825                                try {
826                                        abortConnection(connection);
827                                } catch (Throwable cleanupException) {
828                                        cleanupFailure = appendSuppressed(cleanupFailure, cleanupException);
829                                }
830                        }
831
832                        try {
833                                closeConnection(connection);
834                                getMetricsCollectorDispatcher().didReleaseTransactionConnection(transaction, transaction.getDatabaseType(), heldDuration);
835                        } catch (Throwable cleanupException) {
836                                getMetricsCollectorDispatcher().didFailToReleaseTransactionConnection(transaction, transaction.getDatabaseType(), heldDuration, cleanupException);
837                                cleanupFailure = appendSuppressed(cleanupFailure, cleanupException);
838                        } finally {
839                                // A completed Transaction must never retain a discarded or closed connection handle, including when close fails.
840                                transaction.clearConnection();
841                        }
842                }
843
844                return cleanupFailure;
845        }
846
847        @NonNull
848        private static Throwable appendSuppressed(@Nullable Throwable existing,
849                                                                                                                                                                                @NonNull Throwable additional) {
850                requireNonNull(additional);
851
852                if (existing == null)
853                        return additional;
854
855                if (existing != additional)
856                        existing.addSuppressed(additional);
857
858                return existing;
859        }
860
861        private void closeConnection(@NonNull Connection connection) {
862                requireNonNull(connection);
863
864                try {
865                        connection.close();
866                } catch (SQLException e) {
867                        throw new DatabaseException("Unable to close database connection", e);
868                }
869        }
870
871        private void abortConnection(@NonNull Connection connection) {
872                requireNonNull(connection);
873
874                try {
875                        connection.abort(Runnable::run);
876                } catch (SQLException e) {
877                        throw new DatabaseException("Unable to abort database connection", e);
878                }
879        }
880
881        @NonNull
882        private static DatabaseException databaseExceptionWithStatementContext(@NonNull StatementContext<?> statementContext,
883                                                                                                                                                                                                                                                        @NonNull Throwable cause) {
884                requireNonNull(statementContext);
885                requireNonNull(cause);
886
887                if (cause instanceof DatabaseException databaseException && databaseException.areStatementDiagnosticsApplied())
888                        return databaseException;
889
890                String message = cause.getMessage();
891
892                if (message == null || message.trim().length() == 0)
893                        message = "Database operation failed";
894
895                if (cause instanceof DatabaseException databaseException) {
896                        SecureParameterSupport.DiagnosticScrub diagnosticScrub = diagnosticScrubForStatementContext(statementContext);
897
898                        if (diagnosticScrub.redactor() == null && diagnosticScrub.needleRenderingFailureDescriptions().isEmpty())
899                                return databaseException;
900
901                        return databaseExceptionWithStatementContext(statementContext, message, cause, diagnosticScrub);
902                }
903
904                return databaseExceptionWithStatementContext(statementContext, message, cause);
905        }
906
907        @NonNull
908        private static DatabaseException databaseExceptionWithStatementContext(@NonNull StatementContext<?> statementContext,
909                                                                                                                                                                                                                                                                                                @NonNull String message,
910                                                                                                                                                                                                                                                                                                @NonNull Throwable cause) {
911                requireNonNull(statementContext);
912                requireNonNull(message);
913                requireNonNull(cause);
914
915                SecureParameterSupport.DiagnosticScrub diagnosticScrub = diagnosticScrubForStatementContext(statementContext);
916                return databaseExceptionWithStatementContext(statementContext, message, cause, diagnosticScrub);
917        }
918
919        @NonNull
920        private static DatabaseException databaseExceptionWithStatementContext(@NonNull StatementContext<?> statementContext,
921                                                                                                                                                                                                                                                        @NonNull String message,
922                                                                                                                                                                                                                                                        @NonNull Throwable cause,
923                                                                                                                                                                                                                                                        SecureParameterSupport.@NonNull DiagnosticScrub diagnosticScrub) {
924                requireNonNull(statementContext);
925                requireNonNull(message);
926                requireNonNull(cause);
927                requireNonNull(diagnosticScrub);
928
929                UnaryOperator<String> diagnosticRedactor = diagnosticScrub.redactor();
930
931                // Scrub the RAW message before whitespace-collapsing/truncation: bounding first would break needles
932                // containing consecutive whitespace and can cut a secret mid-way, leaving an unmatchable prefix
933                String scrubbedMessage = diagnosticRedactor == null ? message : diagnosticRedactor.apply(message);
934
935                StatementDiagnostic statementDiagnostic = statementDiagnostic(statementContext);
936                String diagnosticMessage = format("%s [%s]",
937                                boundedDiagnosticMessage(scrubbedMessage), statementDiagnostic.diagnostic());
938                DatabaseException databaseException = cause instanceof DatabaseException original
939                                ? new DatabaseException(diagnosticMessage, original, diagnosticRedactor)
940                                : new DatabaseException(diagnosticMessage, cause,
941                                                databaseDialectForException(statementContext, cause), diagnosticRedactor);
942                databaseException.markStatementDiagnosticsApplied();
943
944                if (statementDiagnostic.parameterRenderingFailure() != null)
945                        databaseException.addSuppressed(statementDiagnostic.parameterRenderingFailure());
946
947                for (String failureDescription : diagnosticScrub.needleRenderingFailureDescriptions())
948                        databaseException.addSuppressed(new RuntimeException(format(
949                                        "Unable to render a secure parameter for diagnostic scrubbing: %s", failureDescription)));
950
951                return databaseException;
952        }
953
954        private static SecureParameterSupport.@NonNull DiagnosticScrub diagnosticScrubForStatementContext(@NonNull StatementContext<?> statementContext) {
955                requireNonNull(statementContext);
956
957                try {
958                        return SecureParameterSupport.diagnosticScrubForParameters(statementContext.getParameters());
959                } catch (Throwable ignored) {
960                        // Per-needle failures are handled inside diagnosticScrubForParameters; reaching here would be
961                        // catastrophic (a plain getter throwing) - fall back to no scrubbing rather than losing the exception
962                        return SecureParameterSupport.DiagnosticScrub.NONE;
963                }
964        }
965
966        @NonNull
967        private static DatabaseDialect databaseDialectForException(@NonNull StatementContext<?> statementContext,
968                                                                                                                                                                                                                                                 @NonNull Throwable cause) {
969                requireNonNull(statementContext);
970                requireNonNull(cause);
971
972                try {
973                        return statementContext.getDatabaseDialect();
974                } catch (Throwable ignored) {
975                        return DatabaseDialect.forExceptionCause(cause);
976                }
977        }
978
979        @NonNull
980        private DatabaseException databaseExceptionWithRawConnectionContext(@Nullable Connection connection,
981                                                                                                                                                                                                                                                                                        @NonNull Exception cause) {
982                requireNonNull(cause);
983
984                return new DatabaseException(cause.getMessage(), cause, databaseDialectForRawConnectionException(connection, cause));
985        }
986
987        @NonNull
988        private DatabaseDialect databaseDialectForRawConnectionException(@Nullable Connection connection,
989                                                                                                                                                                                                                                                                        @NonNull Throwable cause) {
990                requireNonNull(cause);
991
992                if (connection != null) {
993                        try {
994                                return getDatabaseDialect(connection);
995                        } catch (Throwable ignored) {
996                                // Fall through to cause-based classification below.
997                        }
998                }
999
1000                return DatabaseDialect.forExceptionCause(cause);
1001        }
1002
1003        @NonNull
1004        private static StatementDiagnostic statementDiagnostic(@NonNull StatementContext<?> statementContext) {
1005                requireNonNull(statementContext);
1006
1007                Statement statement = statementContext.getStatement();
1008                String parameters;
1009                Throwable parameterRenderingFailure = null;
1010
1011                try {
1012                        parameters = boundedDiagnosticParameters(statementContext.getRedactedParameters());
1013                } catch (Throwable t) {
1014                        parameters = "<unavailable>";
1015                        parameterRenderingFailure = t;
1016                }
1017
1018                String diagnostic = format("statementId=%s, sql=%s, parameters=%s",
1019                                statement.getId(), boundedSql(statement.getSql()), parameters);
1020
1021                if (parameterRenderingFailure != null)
1022                        diagnostic = format("%s, parameterRenderingFailure=%s", diagnostic,
1023                                        boundedDiagnosticParameter(parameterRenderingFailureDiagnostic(parameterRenderingFailure)));
1024
1025                return new StatementDiagnostic(diagnostic, parameterRenderingFailure);
1026        }
1027
1028        private record StatementDiagnostic(@NonNull String diagnostic,
1029                                                                                                                                                 @Nullable Throwable parameterRenderingFailure) {}
1030
1031        @NonNull
1032        private static String parameterRenderingFailureDiagnostic(@NonNull Throwable parameterRenderingFailure) {
1033                requireNonNull(parameterRenderingFailure);
1034
1035                String message;
1036
1037                try {
1038                        message = parameterRenderingFailure.getMessage();
1039                } catch (Throwable ignored) {
1040                        message = null;
1041                }
1042
1043                if (message == null || message.trim().length() == 0)
1044                        return parameterRenderingFailure.getClass().getName();
1045
1046                return format("%s: %s", parameterRenderingFailure.getClass().getName(), message);
1047        }
1048
1049        @NonNull
1050        private static TransactionResult transactionResult(@NonNull Boolean committed,
1051                                                                                                                                                                 @NonNull Boolean transactionOutcomeIndeterminate) {
1052                requireNonNull(committed);
1053                requireNonNull(transactionOutcomeIndeterminate);
1054
1055                if (committed)
1056                        return TransactionResult.COMMITTED;
1057
1058                return transactionOutcomeIndeterminate ? TransactionResult.IN_DOUBT : TransactionResult.ROLLED_BACK;
1059        }
1060
1061        private static MetricsCollector.TransactionClosureOutcome transactionClosureOutcome(@NonNull Boolean committed,
1062                                                                                                                                                                                                                                         @NonNull Boolean hadPhysicalTransaction,
1063                                                                                                                                                                                                                                         @NonNull Boolean transactionOutcomeIndeterminate) {
1064                requireNonNull(committed);
1065                requireNonNull(hadPhysicalTransaction);
1066                requireNonNull(transactionOutcomeIndeterminate);
1067
1068                if (!hadPhysicalTransaction)
1069                        return MetricsCollector.TransactionClosureOutcome.NO_PHYSICAL_TX;
1070
1071                if (committed)
1072                        return MetricsCollector.TransactionClosureOutcome.COMMITTED;
1073
1074                return transactionOutcomeIndeterminate
1075                                ? MetricsCollector.TransactionClosureOutcome.FAILED
1076                                : MetricsCollector.TransactionClosureOutcome.ROLLED_BACK;
1077        }
1078
1079        @Nullable
1080        static Long sumBatchUpdateCounts(@NonNull List<Long> updateCounts) {
1081                requireNonNull(updateCounts);
1082
1083                long total = 0L;
1084
1085                for (Long updateCount : updateCounts) {
1086                        if (updateCount == null || updateCount < 0L)
1087                                return null;
1088
1089                        try {
1090                                total = Math.addExact(total, updateCount);
1091                        } catch (ArithmeticException e) {
1092                                return null;
1093                        }
1094                }
1095
1096                return total;
1097        }
1098
1099        @NonNull
1100        private static String boundedDiagnosticMessage(@NonNull String message) {
1101                requireNonNull(message);
1102
1103                String compactMessage = DIAGNOSTIC_WHITESPACE_PATTERN.matcher(message).replaceAll(" ").trim();
1104
1105                if (compactMessage.length() <= MAX_DIAGNOSTIC_MESSAGE_LENGTH)
1106                        return compactMessage;
1107
1108                int prefixLength = Math.max(0, MAX_DIAGNOSTIC_MESSAGE_LENGTH - TRUNCATED_SUFFIX.length());
1109                return compactMessage.substring(0, prefixLength) + TRUNCATED_SUFFIX;
1110        }
1111
1112        @NonNull
1113        private static String boundedSql(@NonNull String sql) {
1114                requireNonNull(sql);
1115
1116                String compactSql = DIAGNOSTIC_WHITESPACE_PATTERN.matcher(sql).replaceAll(" ").trim();
1117
1118                if (compactSql.length() <= MAX_DIAGNOSTIC_SQL_LENGTH)
1119                        return compactSql;
1120
1121                int prefixLength = Math.max(0, MAX_DIAGNOSTIC_SQL_LENGTH - TRUNCATED_SUFFIX.length());
1122                return compactSql.substring(0, prefixLength) + TRUNCATED_SUFFIX;
1123        }
1124
1125        @NonNull
1126        private static String boundedDiagnosticParameters(@NonNull List<@Nullable Object> parameters) {
1127                requireNonNull(parameters);
1128
1129                StringBuilder parametersBuilder = new StringBuilder("[");
1130
1131                for (int i = 0; i < parameters.size(); ++i) {
1132                        String separator = i == 0 ? "" : ", ";
1133                        String renderedParameter = boundedDiagnosticParameter(parameters.get(i));
1134                        int requiredLength = parametersBuilder.length() + separator.length() + renderedParameter.length() + 1;
1135
1136                        if (requiredLength > MAX_DIAGNOSTIC_PARAMETERS_LENGTH) {
1137                                int availableLength = MAX_DIAGNOSTIC_PARAMETERS_LENGTH
1138                                                - parametersBuilder.length()
1139                                                - separator.length()
1140                                                - TRUNCATED_SUFFIX.length()
1141                                                - 1;
1142
1143                                parametersBuilder.append(separator);
1144
1145                                if (availableLength > 0)
1146                                        parametersBuilder.append(renderedParameter, 0, Math.min(availableLength, renderedParameter.length()));
1147
1148                                parametersBuilder.append(TRUNCATED_SUFFIX).append(']');
1149                                if (parametersBuilder.length() > MAX_DIAGNOSTIC_PARAMETERS_LENGTH) {
1150                                        parametersBuilder.setLength(MAX_DIAGNOSTIC_PARAMETERS_LENGTH - 1);
1151                                        parametersBuilder.append(']');
1152                                }
1153                                return parametersBuilder.toString();
1154                        }
1155
1156                        parametersBuilder.append(separator).append(renderedParameter);
1157                }
1158
1159                parametersBuilder.append(']');
1160                return parametersBuilder.toString();
1161        }
1162
1163        @NonNull
1164        private static String boundedDiagnosticParameter(@Nullable Object parameter) {
1165                String renderedParameter = String.valueOf(parameter);
1166                String compactParameter = DIAGNOSTIC_WHITESPACE_PATTERN.matcher(renderedParameter).replaceAll(" ").trim();
1167
1168                if (compactParameter.length() <= MAX_DIAGNOSTIC_PARAMETER_LENGTH)
1169                        return compactParameter;
1170
1171                int prefixLength = Math.max(0, MAX_DIAGNOSTIC_PARAMETER_LENGTH - TRUNCATED_SUFFIX.length());
1172                return compactParameter.substring(0, prefixLength) + TRUNCATED_SUFFIX;
1173        }
1174
1175        /**
1176         * Performs an operation in the context of a pre-existing transaction.
1177         * <p>
1178         * No commit or rollback on the transaction will occur when {@code transactionalOperation} completes.
1179         * <p>
1180         * However, if an exception bubbles out of {@code transactionalOperation}, the transaction will be marked as rollback-only.
1181         * <p>
1182         * The transaction must have been created by this {@link Database}, or by another {@link Database} using the same
1183         * {@link DataSource} instance.
1184         * <p>
1185         * If this thread is interrupted while waiting for another participant to release the transaction connection, Pyranid
1186         * restores the interrupt flag and throws {@link DatabaseException}.
1187         *
1188         * @param transaction            the transaction in which to participate
1189         * @param transactionalOperation the operation that should participate in the transaction
1190         */
1191        public void participate(@NonNull Transaction transaction,
1192                                                                                                        @NonNull TransactionalOperation transactionalOperation) {
1193                requireNonNull(transaction);
1194                requireNonNull(transactionalOperation);
1195
1196                participate(transaction, () -> {
1197                        transactionalOperation.perform();
1198                        return Optional.empty();
1199                });
1200        }
1201
1202        /**
1203         * Performs an operation in the context of a pre-existing transaction, optionally returning a value.
1204         * <p>
1205         * No commit or rollback on the transaction will occur when {@code transactionalOperation} completes.
1206         * <p>
1207         * However, if an exception bubbles out of {@code transactionalOperation}, the transaction will be marked as rollback-only.
1208         * <p>
1209         * The transaction must have been created by this {@link Database}, or by another {@link Database} using the same
1210         * {@link DataSource} instance.
1211         * <p>
1212         * If this thread is interrupted while waiting for another participant to release the transaction connection, Pyranid
1213         * restores the interrupt flag and throws {@link DatabaseException}.
1214         *
1215         * @param transaction            the transaction in which to participate
1216         * @param transactionalOperation the operation that should participate in the transaction
1217         * @param <T>                    the type to be returned
1218         * @return the result of the transactional operation
1219         */
1220        @NonNull
1221        public <T> Optional<T> participate(@NonNull Transaction transaction,
1222                                                                                                                                                 @NonNull ReturningTransactionalOperation<T> transactionalOperation) {
1223                requireNonNull(transaction);
1224                requireNonNull(transactionalOperation);
1225
1226                if (!isTransactionOwnedByThisDatabase(transaction))
1227                        throw wrongDatabaseTransactionException(transaction);
1228
1229                if (transaction.isCompleted())
1230                        throw new IllegalStateException(format("Transaction %s has already completed and cannot participate", transaction.id()));
1231
1232                Deque<Transaction> transactionStack = transactionStackForPush();
1233                transactionStack.push(transaction);
1234
1235                try {
1236                        Optional<T> returnValue = transactionalOperation.perform();
1237                        return returnValue == null ? Optional.empty() : returnValue;
1238                } catch (RuntimeException e) {
1239                        setRollbackOnlyAfterParticipationFailure(transaction, e);
1240                        restoreInterruptIfNeeded(e);
1241                        throw e;
1242                } catch (Error e) {
1243                        setRollbackOnlyAfterParticipationFailure(transaction, e);
1244                        restoreInterruptIfNeeded(e);
1245                        throw e;
1246                } catch (Throwable t) {
1247                        RuntimeException wrapped = new RuntimeException(t);
1248                        setRollbackOnlyAfterParticipationFailure(transaction, wrapped);
1249                        restoreInterruptIfNeeded(t);
1250                        throw wrapped;
1251                } finally {
1252                        try {
1253                                transactionStack.pop();
1254                        } finally {
1255                                if (transactionStack.isEmpty())
1256                                        TRANSACTION_STACK_HOLDER.remove();
1257                        }
1258                }
1259        }
1260
1261        private void setRollbackOnlyAfterParticipationFailure(@NonNull Transaction transaction,
1262                                                                                                                                                                                                                                @NonNull Throwable primary) {
1263                requireNonNull(transaction);
1264                requireNonNull(primary);
1265
1266                try {
1267                        transaction.setRollbackOnly(true);
1268                } catch (IllegalStateException e) {
1269                        primary.addSuppressed(e);
1270                }
1271        }
1272
1273        /**
1274         * Creates a fluent builder for executing SQL.
1275         * <p>
1276         * Named parameters use the {@code :paramName} syntax and are bound via {@link Query#bind(String, Object)}.
1277         * Positional parameters via {@code ?} are not supported.
1278         * Pyranid ignores parameter-looking text inside SQL string literals, quoted identifiers, comments, PostgreSQL
1279         * dollar-quoted strings, and SQL Server-style bracket-quoted identifiers. PostgreSQL JSONB/hstore {@code ?},
1280         * {@code ?|}, and {@code ?&} operators are supported; when running against {@link DatabaseType#POSTGRESQL}, Pyranid
1281         * emits pgjdbc's escaped {@code ??} form automatically. Unterminated quotes and comments fail fast.
1282         * <p>
1283         * Example:
1284         * <pre>{@code
1285         * Optional<Employee> employee = database.query("SELECT * FROM employee WHERE id = :id")
1286         *   .bind("id", 42)
1287         *   .fetchObject(Employee.class);
1288         * }</pre>
1289         *
1290         * @param sql SQL containing {@code :paramName} placeholders
1291         * @return a fluent builder for binding parameters and executing
1292         * @since 4.0.0
1293         */
1294        @NonNull
1295        public Query query(@NonNull String sql) {
1296                requireNonNull(sql);
1297                return new DefaultQuery(this, sql);
1298        }
1299
1300        /**
1301         * Performs a portable connectivity check using JDBC {@link Connection#isValid(int)}.
1302         * <p>
1303         * This method borrows a fresh connection from this database's {@link DataSource}, calls
1304         * {@link Connection#isValid(int)}, and closes the connection before returning. It does <strong>not</strong>
1305         * participate in an active Pyranid transaction, if one exists.
1306         * <p>
1307         * JDBC accepts timeout values in whole seconds. Positive sub-second durations are rounded up to one second;
1308         * {@link Duration#ZERO} passes a timeout of {@code 0} to the driver.
1309         *
1310         * @param timeout maximum time to wait for driver validation
1311         * @throws IllegalArgumentException if {@code timeout} is negative or too large for JDBC's integer-second timeout
1312         * @throws DatabaseException if connection acquisition fails, validation throws, or the driver reports the
1313         *                           connection is not valid
1314         * @since 4.2.0
1315         */
1316        public void performHealthCheck(@NonNull Duration timeout) {
1317                requireNonNull(timeout);
1318                int timeoutSeconds = healthCheckTimeoutSeconds(timeout);
1319
1320                performRawConnectionOperation(connection -> {
1321                        boolean valid;
1322
1323                        try {
1324                                valid = connection.isValid(timeoutSeconds);
1325                        } catch (SQLException e) {
1326                                throw new DatabaseException("Unable to perform database health check", e);
1327                        }
1328
1329                        if (!valid)
1330                                throw new DatabaseException("Database health check failed: connection is not valid");
1331
1332                        return Optional.empty();
1333                }, false);
1334        }
1335
1336        private static void restoreInterruptIfNeeded(@NonNull Throwable throwable) {
1337                requireNonNull(throwable);
1338
1339                Throwable current = throwable;
1340
1341                while (current != null) {
1342                        if (current instanceof InterruptedException) {
1343                                Thread.currentThread().interrupt();
1344                                return;
1345                        }
1346
1347                        current = current.getCause();
1348                }
1349        }
1350
1351        private static void lockInterruptibly(@NonNull ReentrantLock lock,
1352                                                                                                                                                                @NonNull String operation) {
1353                requireNonNull(lock);
1354                requireNonNull(operation);
1355
1356                try {
1357                        lock.lockInterruptibly();
1358                } catch (InterruptedException e) {
1359                        Thread.currentThread().interrupt();
1360                        throw new DatabaseException(format("Interrupted while waiting to %s", operation), e);
1361                }
1362        }
1363
1364        @Nullable
1365        private static Throwable closeStatementContextResources(@NonNull StatementContext<?> statementContext,
1366                                                                                                                                                                                                                                        @Nullable Throwable cleanupFailure) {
1367                requireNonNull(statementContext);
1368
1369                Queue<AutoCloseable> cleanupOperations = statementContext.getCleanupOperations();
1370                AutoCloseable cleanupOperation;
1371
1372                while ((cleanupOperation = cleanupOperations.poll()) != null) {
1373                        try {
1374                                cleanupOperation.close();
1375                        } catch (Throwable cleanupException) {
1376                                if (cleanupFailure == null)
1377                                        cleanupFailure = cleanupException;
1378                                else
1379                                        cleanupFailure.addSuppressed(cleanupException);
1380                        }
1381                }
1382
1383                return cleanupFailure;
1384        }
1385
1386        private static boolean isUnsupportedSqlFeature(@NonNull SQLException e) {
1387                requireNonNull(e);
1388
1389                String sqlState = e.getSQLState();
1390                if (sqlState != null) {
1391                        if (sqlState.startsWith("0A") || "HYC00".equals(sqlState))
1392                                return true;
1393                }
1394
1395                Throwable cause = e.getCause();
1396                if (cause instanceof SQLFeatureNotSupportedException
1397                                || cause instanceof UnsupportedOperationException
1398                                || cause instanceof AbstractMethodError)
1399                        return true;
1400
1401                String message = e.getMessage();
1402                if (message == null)
1403                        return false;
1404
1405                String lower = message.toLowerCase(Locale.ROOT);
1406                return lower.contains("not supported")
1407                                || lower.contains("unsupported")
1408                                || lower.contains("not implemented")
1409                                || lower.contains("feature not supported");
1410        }
1411
1412        @Nullable
1413        private static Duration validateQueryTimeout(@Nullable Duration queryTimeout) {
1414                if (queryTimeout != null) {
1415                        if (queryTimeout.isNegative())
1416                                throw new IllegalArgumentException("queryTimeout must be >= 0");
1417
1418                        queryTimeoutSeconds(queryTimeout);
1419                }
1420
1421                return queryTimeout;
1422        }
1423
1424        @Nullable
1425        private static Integer validateNonNegativeStatementSetting(@NonNull String name,
1426                                                                                                                                                                                                                                                 @Nullable Integer value) {
1427                requireNonNull(name);
1428
1429                if (value != null && value < 0)
1430                        throw new IllegalArgumentException(format("%s must be >= 0", name));
1431
1432                return value;
1433        }
1434
1435        @Nullable
1436        private static Integer validatePositiveQuerySetting(@NonNull String name,
1437                                                                                                                                                                                                                 @Nullable Integer value) {
1438                requireNonNull(name);
1439
1440                if (value != null && value <= 0)
1441                        throw new IllegalArgumentException(format("%s must be > 0", name));
1442
1443                return value;
1444        }
1445
1446        private static int queryTimeoutSeconds(@NonNull Duration queryTimeout) {
1447                requireNonNull(queryTimeout);
1448
1449                long seconds = queryTimeout.getSeconds();
1450
1451                if (queryTimeout.getNano() > 0) {
1452                        if (seconds == Long.MAX_VALUE)
1453                                throw new IllegalArgumentException(format("queryTimeout must be <= %s seconds", Integer.MAX_VALUE));
1454
1455                        ++seconds;
1456                }
1457
1458                if (seconds > Integer.MAX_VALUE)
1459                        throw new IllegalArgumentException(format("queryTimeout must be <= %s seconds", Integer.MAX_VALUE));
1460
1461                return (int) seconds;
1462        }
1463
1464        private static int healthCheckTimeoutSeconds(@NonNull Duration timeout) {
1465                requireNonNull(timeout);
1466
1467                if (timeout.isNegative())
1468                        throw new IllegalArgumentException("timeout must be >= 0");
1469
1470                long seconds = timeout.getSeconds();
1471
1472                if (timeout.getNano() > 0) {
1473                        if (seconds == Long.MAX_VALUE)
1474                                throw new IllegalArgumentException(format("timeout must be <= %s seconds", Integer.MAX_VALUE));
1475
1476                        ++seconds;
1477                }
1478
1479                if (seconds > Integer.MAX_VALUE)
1480                        throw new IllegalArgumentException(format("timeout must be <= %s seconds", Integer.MAX_VALUE));
1481
1482                return (int) seconds;
1483        }
1484
1485        @NonNull
1486        private ParsedSqlVariants getParsedSqlVariants(@NonNull String sql) {
1487                requireNonNull(sql);
1488
1489                if (this.parsedSqlCache == null)
1490                        return parseNamedParameterSqlVariants(sql);
1491
1492                return this.parsedSqlCache.computeIfAbsent(sql, Database::parseNamedParameterSqlVariants);
1493        }
1494
1495        @NonNull
1496        private static ParsedSqlVariants parseNamedParameterSqlVariants(@NonNull String sql) {
1497                requireNonNull(sql);
1498
1499                ParsedSql standardParsedSql = null;
1500                ParsedSql mysqlParsedSql = null;
1501                IllegalArgumentException standardParseFailure = null;
1502                IllegalArgumentException mysqlParseFailure = null;
1503
1504                try {
1505                        standardParsedSql = parseNamedParameterSql(sql, SqlLexicalMode.STANDARD);
1506                } catch (IllegalArgumentException e) {
1507                        standardParseFailure = e;
1508                }
1509
1510                if (requiresMySqlLexicalVariant(sql)) {
1511                        try {
1512                                mysqlParsedSql = parseNamedParameterSql(sql, SqlLexicalMode.MYSQL);
1513                        } catch (IllegalArgumentException e) {
1514                                mysqlParseFailure = e;
1515                        }
1516                } else {
1517                        mysqlParsedSql = standardParsedSql;
1518                        mysqlParseFailure = standardParseFailure;
1519                }
1520
1521                if (standardParsedSql == null && mysqlParsedSql == null)
1522                        throw requireNonNull(standardParseFailure);
1523
1524                ParsedSql effectiveStandardParsedSql = standardParsedSql == null
1525                                ? requireNonNull(mysqlParsedSql)
1526                                : standardParsedSql;
1527                ParsedSql effectiveMysqlParsedSql = mysqlParsedSql == null
1528                                ? effectiveStandardParsedSql
1529                                : mysqlParsedSql;
1530                Set<String> distinctParameterNames;
1531
1532                if (effectiveStandardParsedSql.distinctParameterNames.equals(effectiveMysqlParsedSql.distinctParameterNames)) {
1533                        distinctParameterNames = effectiveStandardParsedSql.distinctParameterNames;
1534                } else {
1535                        Set<String> combinedParameterNames = new HashSet<>(effectiveStandardParsedSql.distinctParameterNames);
1536                        combinedParameterNames.addAll(effectiveMysqlParsedSql.distinctParameterNames);
1537                        distinctParameterNames = Set.copyOf(combinedParameterNames);
1538                }
1539
1540                boolean requiresDatabaseType = standardParseFailure != null
1541                                || mysqlParseFailure != null
1542                                || !effectiveStandardParsedSql.equivalentTo(effectiveMysqlParsedSql);
1543
1544                return new ParsedSqlVariants(effectiveStandardParsedSql,
1545                                effectiveMysqlParsedSql,
1546                                standardParseFailure == null ? null : requireNonNull(standardParseFailure.getMessage()),
1547                                mysqlParseFailure == null ? null : requireNonNull(mysqlParseFailure.getMessage()),
1548                                distinctParameterNames,
1549                                requiresDatabaseType);
1550        }
1551
1552        private static boolean requiresMySqlLexicalVariant(@NonNull String sql) {
1553                requireNonNull(sql);
1554                return sql.indexOf('#') >= 0 || sql.indexOf("\\'") >= 0;
1555        }
1556
1557        private static final class ParsedSqlVariants {
1558                @NonNull
1559                private final ParsedSql standardParsedSql;
1560                @NonNull
1561                private final ParsedSql mysqlParsedSql;
1562                @Nullable
1563                private final String standardParseFailureMessage;
1564                @Nullable
1565                private final String mysqlParseFailureMessage;
1566                @NonNull
1567                private final Set<String> distinctParameterNames;
1568                private final boolean requiresDatabaseType;
1569
1570                private ParsedSqlVariants(@NonNull ParsedSql standardParsedSql,
1571                                                                                                                @NonNull ParsedSql mysqlParsedSql,
1572                                                                                                                @Nullable String standardParseFailureMessage,
1573                                                                                                                @Nullable String mysqlParseFailureMessage,
1574                                                                                                                @NonNull Set<String> distinctParameterNames,
1575                                                                                                                boolean requiresDatabaseType) {
1576                        this.standardParsedSql = requireNonNull(standardParsedSql);
1577                        this.mysqlParsedSql = requireNonNull(mysqlParsedSql);
1578                        this.standardParseFailureMessage = standardParseFailureMessage;
1579                        this.mysqlParseFailureMessage = mysqlParseFailureMessage;
1580                        this.distinctParameterNames = Set.copyOf(requireNonNull(distinctParameterNames));
1581                        this.requiresDatabaseType = requiresDatabaseType;
1582                }
1583        }
1584
1585        /**
1586         * Default internal implementation of {@link Query}.
1587         * <p>
1588         * This class is intended for use by a single thread.
1589         */
1590        @NotThreadSafe
1591        private static final class DefaultQuery implements Query {
1592                @NonNull
1593                private final Database database;
1594                @NonNull
1595                private final String originalSql;
1596                @NonNull
1597                private final ParsedSqlVariants parsedSqlVariants;
1598                @NonNull
1599                private final Map<String, Object> bindings;
1600                @Nullable
1601                private PreparedStatementCustomizer preparedStatementCustomizer;
1602                @Nullable
1603                private Duration queryTimeout;
1604                @Nullable
1605                private Integer fetchSize;
1606                @Nullable
1607                private Integer maxRows;
1608                @Nullable
1609                private Integer batchChunkSize;
1610                @Nullable
1611                private Object id;
1612                @Nullable
1613                private ResultSetMapper resultSetMapper;
1614                @Nullable
1615                private PreparedStatementBinder preparedStatementBinder;
1616
1617                private DefaultQuery(@NonNull Database database,
1618                                                                                                 @NonNull String sql) {
1619                        requireNonNull(database);
1620                        requireNonNull(sql);
1621
1622                        this.database = database;
1623                        this.originalSql = sql;
1624                        this.parsedSqlVariants = database.getParsedSqlVariants(sql);
1625
1626                        this.bindings = new LinkedHashMap<>(Math.max(8, this.parsedSqlVariants.distinctParameterNames.size()));
1627                        this.preparedStatementCustomizer = null;
1628                        this.queryTimeout = null;
1629                        this.fetchSize = null;
1630                        this.maxRows = null;
1631                        this.batchChunkSize = null;
1632                }
1633
1634                @NonNull
1635                @Override
1636                public Query bind(@NonNull String name,
1637                                                                                        @Nullable Object value) {
1638                        requireNonNull(name);
1639
1640                        if (!this.parsedSqlVariants.distinctParameterNames.contains(name))
1641                                throw new IllegalArgumentException(format("Unknown named parameter '%s' for SQL: %s", name, this.originalSql));
1642
1643                        this.bindings.put(name, value);
1644                        return this;
1645                }
1646
1647                @NonNull
1648                @Override
1649                public Query bindAll(@NonNull Map<@NonNull String, @Nullable Object> parameters) {
1650                        requireNonNull(parameters);
1651
1652                        for (Map.Entry<@NonNull String, @Nullable Object> entry : parameters.entrySet())
1653                                bind(entry.getKey(), entry.getValue());
1654
1655                        return this;
1656                }
1657
1658                @NonNull
1659                @Override
1660                public Query id(@Nullable Object id) {
1661                        this.id = id;
1662                        return this;
1663                }
1664
1665                @NonNull
1666                @Override
1667                public Query queryTimeout(@Nullable Duration queryTimeout) {
1668                        this.queryTimeout = validateQueryTimeout(queryTimeout);
1669                        return this;
1670                }
1671
1672                @NonNull
1673                @Override
1674                public Query fetchSize(@Nullable Integer fetchSize) {
1675                        this.fetchSize = validateNonNegativeStatementSetting("fetchSize", fetchSize);
1676                        return this;
1677                }
1678
1679                @NonNull
1680                @Override
1681                public Query maxRows(@Nullable Integer maxRows) {
1682                        this.maxRows = validateNonNegativeStatementSetting("maxRows", maxRows);
1683                        return this;
1684                }
1685
1686                @NonNull
1687                @Override
1688                public Query batchChunkSize(@Nullable Integer batchChunkSize) {
1689                        this.batchChunkSize = validatePositiveQuerySetting("batchChunkSize", batchChunkSize);
1690                        return this;
1691                }
1692
1693                @NonNull
1694                @Override
1695                public Query resultSetMapper(@Nullable ResultSetMapper resultSetMapper) {
1696                        this.resultSetMapper = resultSetMapper;
1697                        return this;
1698                }
1699
1700                @NonNull
1701                @Override
1702                public Query preparedStatementBinder(@Nullable PreparedStatementBinder preparedStatementBinder) {
1703                        this.preparedStatementBinder = preparedStatementBinder;
1704                        return this;
1705                }
1706
1707                private StatementContext.@Nullable SpiOverrides spiOverrides() {
1708                        return this.resultSetMapper == null && this.preparedStatementBinder == null
1709                                        ? null
1710                                        : new StatementContext.SpiOverrides(this.resultSetMapper, this.preparedStatementBinder);
1711                }
1712
1713                @NonNull
1714                @Override
1715                public Query customize(@NonNull PreparedStatementCustomizer preparedStatementCustomizer) {
1716                        requireNonNull(preparedStatementCustomizer);
1717                        this.preparedStatementCustomizer = preparedStatementCustomizer;
1718
1719                        return this;
1720                }
1721
1722                @NonNull
1723                @Override
1724                public <T> Optional<T> fetchObject(@NonNull Class<T> resultType) {
1725                        validateBatchChunkSizeNotSet();
1726                        requireNonNull(resultType);
1727                        PreparedQuery preparedQuery = prepare(this.bindings);
1728                        return this.database.queryForObject(preparedQuery.statement, resultType, effectivePreparedStatementCustomizer(), spiOverrides(), preparedQuery.parameters);
1729                }
1730
1731                @NonNull
1732                @Override
1733                public <T> List<@Nullable T> fetchList(@NonNull Class<T> resultType) {
1734                        validateBatchChunkSizeNotSet();
1735                        requireNonNull(resultType);
1736                        PreparedQuery preparedQuery = prepare(this.bindings);
1737                        return this.database.queryForList(preparedQuery.statement, resultType, effectivePreparedStatementCustomizer(), spiOverrides(), preparedQuery.parameters);
1738                }
1739
1740                @Nullable
1741                @Override
1742                public <T, R> R fetchStream(@NonNull Class<T> resultType,
1743                                                                                                                                @NonNull Function<Stream<@Nullable T>, R> streamFunction) {
1744                        validateBatchChunkSizeNotSet();
1745                        requireNonNull(resultType);
1746                        requireNonNull(streamFunction);
1747                        PreparedQuery preparedQuery = prepare(this.bindings);
1748                        return this.database.queryForStream(preparedQuery.statement, resultType, effectivePreparedStatementCustomizer(),
1749                                        this.fetchSize != null, streamFunction, spiOverrides(), preparedQuery.parameters);
1750                }
1751
1752
1753                @NonNull
1754                @Override
1755                public Long execute() {
1756                        validateBatchChunkSizeNotSet();
1757                        PreparedQuery preparedQuery = prepare(this.bindings);
1758                        return this.database.execute(preparedQuery.statement, effectivePreparedStatementCustomizer(), spiOverrides(), preparedQuery.parameters);
1759                }
1760
1761                @NonNull
1762                @Override
1763                public <T> Optional<T> executeReturningGeneratedKey(@NonNull Class<T> resultType) {
1764                        validateBatchChunkSizeNotSet();
1765                        requireNonNull(resultType);
1766                        PreparedQuery preparedQuery = prepare(this.bindings);
1767                        return this.database.executeReturningGeneratedKey(preparedQuery.statement, resultType,
1768                                        effectivePreparedStatementCustomizer(), new String[0], spiOverrides(), preparedQuery.parameters);
1769                }
1770
1771                @NonNull
1772                @Override
1773                public <T> Optional<T> executeReturningGeneratedKey(@NonNull Class<T> resultType,
1774                                                                                                                                                                                                                                @NonNull String @NonNull ... keyColumnNames) {
1775                        validateBatchChunkSizeNotSet();
1776                        requireNonNull(resultType);
1777                        PreparedQuery preparedQuery = prepare(this.bindings);
1778                        return this.database.executeReturningGeneratedKey(preparedQuery.statement, resultType,
1779                                        effectivePreparedStatementCustomizer(), keyColumnNames, spiOverrides(), preparedQuery.parameters);
1780                }
1781
1782                @NonNull
1783                @Override
1784                public <T> List<@Nullable T> executeReturningGeneratedKeys(@NonNull Class<T> resultType) {
1785                        validateBatchChunkSizeNotSet();
1786                        requireNonNull(resultType);
1787                        PreparedQuery preparedQuery = prepare(this.bindings);
1788                        return this.database.executeReturningGeneratedKeys(preparedQuery.statement, resultType,
1789                                        effectivePreparedStatementCustomizer(), new String[0], spiOverrides(), preparedQuery.parameters);
1790                }
1791
1792                @NonNull
1793                @Override
1794                public <T> List<@Nullable T> executeReturningGeneratedKeys(@NonNull Class<T> resultType,
1795                                                                                                                                                                                                                                         @NonNull String @NonNull ... keyColumnNames) {
1796                        validateBatchChunkSizeNotSet();
1797                        requireNonNull(resultType);
1798                        PreparedQuery preparedQuery = prepare(this.bindings);
1799                        return this.database.executeReturningGeneratedKeys(preparedQuery.statement, resultType,
1800                                        effectivePreparedStatementCustomizer(), keyColumnNames, spiOverrides(), preparedQuery.parameters);
1801                }
1802
1803                @NonNull
1804                @Override
1805                public List<Long> executeBatch(@NonNull List<@NonNull Map<@NonNull String, @Nullable Object>> parameterGroups) {
1806                        requireNonNull(parameterGroups);
1807                        if (parameterGroups.isEmpty())
1808                                return List.of();
1809
1810                        List<List<Object>> parametersAsList = new ArrayList<>(parameterGroups.size());
1811                        Object statementId = this.id == null ? this.database.generateId() : this.id;
1812                        Statement statement = null;
1813                        String expandedSql = null;
1814
1815                        for (Map<@NonNull String, @Nullable Object> parameterGroup : parameterGroups) {
1816                                requireNonNull(parameterGroup);
1817
1818                                for (String parameterName : parameterGroup.keySet())
1819                                        if (!this.parsedSqlVariants.distinctParameterNames.contains(parameterName))
1820                                                throw new IllegalArgumentException(format("Unknown named parameter '%s' for SQL: %s", parameterName, this.originalSql));
1821
1822                                Map<String, Object> mergedBindings;
1823                                if (this.bindings.isEmpty()) {
1824                                        mergedBindings = parameterGroup;
1825                                } else if (parameterGroup.isEmpty()) {
1826                                        mergedBindings = this.bindings;
1827                                } else {
1828                                        Map<String, Object> combinedBindings = new LinkedHashMap<>(this.bindings);
1829                                        combinedBindings.putAll(parameterGroup);
1830                                        mergedBindings = combinedBindings;
1831                                }
1832
1833                                PreparedQuery preparedQuery = prepare(mergedBindings, statementId);
1834
1835                                if (expandedSql == null) {
1836                                        expandedSql = preparedQuery.statement.getSql();
1837                                        statement = preparedQuery.statement;
1838                                } else if (!expandedSql.equals(preparedQuery.statement.getSql())) {
1839                                        throw new IllegalArgumentException(format(
1840                                                        "Inconsistent SQL after expanding parameters for batch execution; ensure collection sizes are consistent. SQL: %s",
1841                                                        this.originalSql));
1842                                }
1843
1844                                parametersAsList.add(Arrays.asList(preparedQuery.parameters));
1845                        }
1846
1847                        if (statement == null)
1848                                statement = Statement.of(statementId, buildPlaceholderSql());
1849
1850                        return this.database.executeBatch(statement, parametersAsList, effectivePreparedStatementCustomizer(), this.batchChunkSize, spiOverrides());
1851                }
1852
1853                @NonNull
1854                @Override
1855                public <T> Optional<T> executeForObject(@NonNull Class<T> resultType) {
1856                        validateBatchChunkSizeNotSet();
1857                        requireNonNull(resultType);
1858                        PreparedQuery preparedQuery = prepare(this.bindings);
1859                        return this.database.executeForObject(preparedQuery.statement, resultType, effectivePreparedStatementCustomizer(), spiOverrides(), preparedQuery.parameters);
1860                }
1861
1862                @NonNull
1863                @Override
1864                public <T> List<@Nullable T> executeForList(@NonNull Class<T> resultType) {
1865                        validateBatchChunkSizeNotSet();
1866                        requireNonNull(resultType);
1867                        PreparedQuery preparedQuery = prepare(this.bindings);
1868                        return this.database.executeForList(preparedQuery.statement, resultType, effectivePreparedStatementCustomizer(), spiOverrides(), preparedQuery.parameters);
1869                }
1870
1871                private void validateBatchChunkSizeNotSet() {
1872                        if (this.batchChunkSize != null)
1873                                throw new IllegalStateException("batchChunkSize applies only to executeBatch(...)");
1874                }
1875
1876                @Nullable
1877                private PreparedStatementCustomizer effectivePreparedStatementCustomizer() {
1878                        if (!this.database.hasDefaultPreparedStatementSettings()
1879                                        && !hasQueryPreparedStatementSettings()
1880                                        && this.preparedStatementCustomizer == null)
1881                                return null;
1882
1883                        return (statementContext, preparedStatement) -> {
1884                                this.database.applyDefaultPreparedStatementSettings(preparedStatement);
1885                                applyPreparedStatementSettings(preparedStatement, this.queryTimeout, this.fetchSize, this.maxRows);
1886
1887                                if (this.preparedStatementCustomizer != null)
1888                                        this.preparedStatementCustomizer.customize(statementContext, preparedStatement);
1889                        };
1890                }
1891
1892                private boolean hasQueryPreparedStatementSettings() {
1893                        return this.queryTimeout != null || this.fetchSize != null || this.maxRows != null;
1894                }
1895
1896                @NonNull
1897                private PreparedQuery prepare(@NonNull Map<String, Object> bindings) {
1898                        Object statementId = this.id == null ? this.database.generateId() : this.id;
1899                        return prepare(bindings, statementId);
1900                }
1901
1902                @NonNull
1903                private PreparedQuery prepare(@NonNull Map<String, Object> bindings,
1904                                                                                                                                        @NonNull Object statementId) {
1905                        requireNonNull(bindings);
1906                        requireNonNull(statementId);
1907
1908                        ParsedSql parsedSql = parsedSqlForDatabaseType();
1909                        List<String> parameterNames = parsedSql.parameterNames;
1910                        List<String> sqlFragments = sqlFragmentsForDatabaseType(parsedSql);
1911                        validateBindingsForParsedSql(bindings, parsedSql);
1912
1913                        if (parameterNames.isEmpty())
1914                                return new PreparedQuery(Statement.of(statementId, sqlFragments.get(0)), new Object[0]);
1915
1916                        StringBuilder sql = new StringBuilder(this.originalSql.length() + parameterNames.size() * 2);
1917                        List<String> missingParameterNames = null;
1918                        List<Object> parameters = new ArrayList<>(parameterNames.size());
1919
1920                        for (int i = 0; i < parameterNames.size(); ++i) {
1921                                String parameterName = parameterNames.get(i);
1922                                sql.append(sqlFragments.get(i));
1923
1924                                if (!bindings.containsKey(parameterName)) {
1925                                        if (missingParameterNames == null)
1926                                                missingParameterNames = new ArrayList<>();
1927
1928                                        missingParameterNames.add(parameterName);
1929                                        sql.append('?');
1930                                        continue;
1931                                }
1932
1933                                SecureParameterSupport.SecureParameterUnwrapResult secureParameterUnwrapResult =
1934                                                SecureParameterSupport.unwrapSecureAndOptionalParameterWithMetadata(bindings.get(parameterName));
1935                                SecureParameter secureParameter = secureParameterUnwrapResult.secureParameter();
1936                                Object value = secureParameterUnwrapResult.value();
1937
1938                                if (value instanceof InListParameter inListParameter) {
1939                                        Object[] elements = inListParameter.getElements();
1940
1941                                        if (elements.length == 0)
1942                                                throw new IllegalArgumentException(format("IN-list parameter '%s' for SQL: %s is empty", parameterName, this.originalSql));
1943
1944                                        appendPlaceholders(sql, elements.length);
1945
1946                                        for (int j = 0; j < elements.length; ++j) {
1947                                                SecureParameterSupport.SecureParameterUnwrapResult elementUnwrapResult =
1948                                                                SecureParameterSupport.unwrapSecureAndOptionalParameterWithMetadata(elements[j]);
1949                                                Object element = elementUnwrapResult.value();
1950
1951                                                if (element == null)
1952                                                        throw new IllegalArgumentException(format(
1953                                                                        "IN-list parameter '%s' for SQL: %s contains null element at index %d. "
1954                                                                                        + "SQL IN does not match NULL values; use an explicit IS NULL predicate instead.",
1955                                                                        parameterName, this.originalSql, j));
1956
1957                                                SecureParameter effectiveSecureParameter = secureParameter == null
1958                                                                ? elementUnwrapResult.secureParameter()
1959                                                                : secureParameter;
1960                                                parameters.add(effectiveSecureParameter == null
1961                                                                ? element
1962                                                                : Parameters.secure(element, SecureParameterSupport.maskOf(effectiveSecureParameter)));
1963                                        }
1964                                } else if (value instanceof Collection<?>) {
1965                                        throw new IllegalArgumentException(format(
1966                                                        "Collection parameter '%s' for SQL: %s must be wrapped with %s.inList(...) or %s.listOf/%s.setOf(...)",
1967                                                        parameterName, this.originalSql,
1968                                                        Parameters.class.getSimpleName(),
1969                                                        Parameters.class.getSimpleName(), Parameters.class.getSimpleName()));
1970                                } else if (value != null && value.getClass().isArray() && !(value instanceof byte[])) {
1971                                        throw new IllegalArgumentException(format(
1972                                                        "Array parameter '%s' for SQL: %s must be wrapped with %s.inList(...), %s.sqlArrayOf(...), or %s.arrayOf(Class, ...)",
1973                                                        parameterName, this.originalSql,
1974                                                        Parameters.class.getSimpleName(), Parameters.class.getSimpleName(), Parameters.class.getSimpleName()));
1975                                } else {
1976                                        sql.append('?');
1977                                        parameters.add(secureParameter == null ? value : secureParameter);
1978                                }
1979                        }
1980
1981                        sql.append(sqlFragments.get(sqlFragments.size() - 1));
1982
1983                        if (missingParameterNames != null)
1984                                throw new IllegalArgumentException(format("Missing required named parameters %s for SQL: %s", missingParameterNames, this.originalSql));
1985
1986                        return new PreparedQuery(Statement.of(statementId, sql.toString()), parameters.toArray());
1987                }
1988
1989                @NonNull
1990                private String buildPlaceholderSql() {
1991                        ParsedSql parsedSql = parsedSqlForDatabaseType();
1992                        List<String> parameterNames = parsedSql.parameterNames;
1993                        List<String> sqlFragments = sqlFragmentsForDatabaseType(parsedSql);
1994
1995                        if (parameterNames.isEmpty())
1996                                return sqlFragments.get(0);
1997
1998                        StringBuilder sql = new StringBuilder(this.originalSql.length() + parameterNames.size() * 2);
1999
2000                        for (int i = 0; i < parameterNames.size(); ++i)
2001                                sql.append(sqlFragments.get(i)).append('?');
2002
2003                        sql.append(sqlFragments.get(sqlFragments.size() - 1));
2004                        return sql.toString();
2005                }
2006
2007                @NonNull
2008                private List<String> sqlFragmentsForDatabaseType(@NonNull ParsedSql parsedSql) {
2009                        requireNonNull(parsedSql);
2010
2011                        if (!parsedSql.hasQuestionMarkOperators)
2012                                return parsedSql.sqlFragments;
2013
2014                        return this.database.getDatabaseDialect().sqlFragmentsForOperators(
2015                                        parsedSql.hasQuestionMarkOperators,
2016                                        parsedSql.sqlFragments,
2017                                        parsedSql.questionMarkOperatorFragmentIndexes);
2018                }
2019
2020                @NonNull
2021                private ParsedSql parsedSqlForDatabaseType() {
2022                        if (!this.parsedSqlVariants.requiresDatabaseType)
2023                                return this.parsedSqlVariants.standardParsedSql;
2024
2025                        DatabaseType databaseType;
2026                        Optional<Transaction> transaction = this.database.currentTransactionForDatabaseOperation();
2027
2028                        if (transaction.isPresent())
2029                                databaseType = this.database.getDatabaseType(transaction.get().getConnection());
2030                        else
2031                                databaseType = this.database.getDatabaseType();
2032
2033                        if (databaseType == DatabaseType.MYSQL || databaseType == DatabaseType.MARIA_DB) {
2034                                if (this.parsedSqlVariants.mysqlParseFailureMessage != null)
2035                                        throw new IllegalArgumentException(this.parsedSqlVariants.mysqlParseFailureMessage);
2036
2037                                return this.parsedSqlVariants.mysqlParsedSql;
2038                        }
2039
2040                        if (this.parsedSqlVariants.standardParseFailureMessage != null)
2041                                throw new IllegalArgumentException(this.parsedSqlVariants.standardParseFailureMessage);
2042
2043                        return this.parsedSqlVariants.standardParsedSql;
2044                }
2045
2046                private void validateBindingsForParsedSql(@NonNull Map<String, Object> bindings,
2047                                                                                                                                                                         @NonNull ParsedSql parsedSql) {
2048                        requireNonNull(bindings);
2049                        requireNonNull(parsedSql);
2050
2051                        for (String bindingName : bindings.keySet())
2052                                if (!parsedSql.distinctParameterNames.contains(bindingName))
2053                                        throw new IllegalArgumentException(format(
2054                                                        "Named parameter '%s' is not valid for the detected database SQL syntax. SQL: %s",
2055                                                        bindingName, this.originalSql));
2056                }
2057
2058                private void appendPlaceholders(@NonNull StringBuilder sql,
2059                                                                                                                                                int count) {
2060                        requireNonNull(sql);
2061
2062                        for (int i = 0; i < count; ++i) {
2063                                if (i > 0)
2064                                        sql.append(", ");
2065                                sql.append('?');
2066                        }
2067                }
2068
2069                private static final class PreparedQuery {
2070                        @NonNull
2071                        private final Statement statement;
2072                        @NonNull
2073                        private final Object @NonNull [] parameters;
2074
2075                        private PreparedQuery(@NonNull Statement statement,
2076                                                                                                                Object @NonNull [] parameters) {
2077                                this.statement = requireNonNull(statement);
2078                                this.parameters = requireNonNull(parameters);
2079                        }
2080                }
2081
2082        }
2083
2084        static final class ParsedSql {
2085                @NonNull
2086                private final List<String> sqlFragments;
2087                @NonNull
2088                private final List<@NonNull List<@NonNull Integer>> questionMarkOperatorFragmentIndexes;
2089                private final boolean hasQuestionMarkOperators;
2090                @NonNull
2091                private final List<String> parameterNames;
2092                @NonNull
2093                private final Set<String> distinctParameterNames;
2094
2095                private ParsedSql(@NonNull List<String> sqlFragments,
2096                                                                                        @NonNull List<@NonNull List<@NonNull Integer>> questionMarkOperatorFragmentIndexes,
2097                                                                                        @NonNull List<String> parameterNames,
2098                                                                                        @NonNull Set<String> distinctParameterNames) {
2099                        requireNonNull(sqlFragments);
2100                        requireNonNull(questionMarkOperatorFragmentIndexes);
2101                        requireNonNull(parameterNames);
2102                        requireNonNull(distinctParameterNames);
2103
2104                        this.sqlFragments = sqlFragments;
2105                        this.questionMarkOperatorFragmentIndexes = questionMarkOperatorFragmentIndexes.stream()
2106                                        .map(List::copyOf)
2107                                        .toList();
2108                        this.hasQuestionMarkOperators = questionMarkOperatorFragmentIndexes.stream().anyMatch(indexes -> !indexes.isEmpty());
2109                        this.parameterNames = parameterNames;
2110                        this.distinctParameterNames = distinctParameterNames;
2111                }
2112
2113                private boolean equivalentTo(@NonNull ParsedSql other) {
2114                        requireNonNull(other);
2115                        return this.sqlFragments.equals(other.sqlFragments)
2116                                        && this.questionMarkOperatorFragmentIndexes.equals(other.questionMarkOperatorFragmentIndexes)
2117                                        && this.parameterNames.equals(other.parameterNames);
2118                }
2119        }
2120
2121        enum SqlLexicalMode {
2122                STANDARD,
2123                MYSQL
2124        }
2125
2126        @NonNull
2127        static ParsedSql parseNamedParameterSql(@NonNull String sql) {
2128                return parseNamedParameterSql(sql, SqlLexicalMode.STANDARD);
2129        }
2130
2131        @NonNull
2132        static ParsedSql parseNamedParameterSql(@NonNull String sql,
2133                                                                                                                                                                        @NonNull SqlLexicalMode lexicalMode) {
2134                requireNonNull(sql);
2135                requireNonNull(lexicalMode);
2136
2137                List<String> sqlFragments = new ArrayList<>();
2138                StringBuilder sqlFragment = new StringBuilder(sql.length());
2139                List<List<Integer>> questionMarkOperatorFragmentIndexes = new ArrayList<>();
2140                List<Integer> currentQuestionMarkOperatorIndexes = new ArrayList<>();
2141                List<String> parameterNames = new ArrayList<>();
2142                Set<String> distinctParameterNames = new HashSet<>();
2143
2144                boolean inSingleQuote = false;
2145                boolean inSingleQuoteEscapesBackslash = false;
2146                int singleQuoteStartIndex = -1;
2147                boolean inDoubleQuote = false;
2148                int doubleQuoteStartIndex = -1;
2149                boolean inBacktickQuote = false;
2150                int backtickQuoteStartIndex = -1;
2151                boolean inBracketQuote = false;
2152                int bracketQuoteStartIndex = -1;
2153                boolean inLineComment = false;
2154                int blockCommentDepth = 0;
2155                int blockCommentStartIndex = -1;
2156                String dollarQuoteDelimiter = null;
2157                int dollarQuoteStartIndex = -1;
2158                Character oracleQuoteClosingDelimiter = null;
2159                int oracleQuoteStartIndex = -1;
2160                int previousMeaningfulIndex = -1;
2161
2162                for (int i = 0; i < sql.length(); ) {
2163                        if (oracleQuoteClosingDelimiter != null) {
2164                                char c = sql.charAt(i);
2165                                sqlFragment.append(c);
2166
2167                                if (c == oracleQuoteClosingDelimiter && i + 1 < sql.length() && sql.charAt(i + 1) == '\'') {
2168                                        sqlFragment.append('\'');
2169                                        i += 2;
2170                                        oracleQuoteClosingDelimiter = null;
2171                                        oracleQuoteStartIndex = -1;
2172                                        previousMeaningfulIndex = i - 1;
2173                                } else {
2174                                        ++i;
2175                                }
2176
2177                                continue;
2178                        }
2179
2180                        if (dollarQuoteDelimiter != null) {
2181                                if (sql.startsWith(dollarQuoteDelimiter, i)) {
2182                                        sqlFragment.append(dollarQuoteDelimiter);
2183                                        previousMeaningfulIndex = i + dollarQuoteDelimiter.length() - 1;
2184                                        i += dollarQuoteDelimiter.length();
2185                                        dollarQuoteDelimiter = null;
2186                                        dollarQuoteStartIndex = -1;
2187                                } else {
2188                                        sqlFragment.append(sql.charAt(i));
2189                                        ++i;
2190                                }
2191
2192                                continue;
2193                        }
2194
2195                        char c = sql.charAt(i);
2196
2197                        if (inLineComment) {
2198                                sqlFragment.append(c);
2199                                ++i;
2200
2201                                if (c == '\n' || c == '\r')
2202                                        inLineComment = false;
2203
2204                                continue;
2205                        }
2206
2207                        if (blockCommentDepth > 0) {
2208                                if (c == '/' && i + 1 < sql.length() && sql.charAt(i + 1) == '*') {
2209                                        sqlFragment.append("/*");
2210                                        i += 2;
2211                                        ++blockCommentDepth;
2212                                } else if (c == '*' && i + 1 < sql.length() && sql.charAt(i + 1) == '/') {
2213                                        sqlFragment.append("*/");
2214                                        i += 2;
2215                                        --blockCommentDepth;
2216                                        if (blockCommentDepth == 0)
2217                                                blockCommentStartIndex = -1;
2218                                } else {
2219                                        sqlFragment.append(c);
2220                                        ++i;
2221                                }
2222
2223                                continue;
2224                        }
2225
2226                        if (inSingleQuote) {
2227                                sqlFragment.append(c);
2228
2229                                if (inSingleQuoteEscapesBackslash && c == '\\' && i + 1 < sql.length()) {
2230                                        sqlFragment.append(sql.charAt(i + 1));
2231                                        i += 2;
2232                                        continue;
2233                                }
2234
2235                                if (c == '\'') {
2236                                        // Escaped quote: ''
2237                                        if (i + 1 < sql.length() && sql.charAt(i + 1) == '\'') {
2238                                                sqlFragment.append('\'');
2239                                                i += 2;
2240                                                continue;
2241                                        }
2242
2243                                        inSingleQuote = false;
2244                                        inSingleQuoteEscapesBackslash = false;
2245                                        singleQuoteStartIndex = -1;
2246                                        previousMeaningfulIndex = i;
2247                                }
2248
2249                                ++i;
2250                                continue;
2251                        }
2252
2253                        if (inDoubleQuote) {
2254                                sqlFragment.append(c);
2255
2256                                if (c == '"') {
2257                                        // Escaped quote: ""
2258                                        if (i + 1 < sql.length() && sql.charAt(i + 1) == '"') {
2259                                                sqlFragment.append('"');
2260                                                i += 2;
2261                                                continue;
2262                                        }
2263
2264                                        inDoubleQuote = false;
2265                                        doubleQuoteStartIndex = -1;
2266                                        previousMeaningfulIndex = i;
2267                                }
2268
2269                                ++i;
2270                                continue;
2271                        }
2272
2273                        if (inBacktickQuote) {
2274                                sqlFragment.append(c);
2275
2276                                if (c == '`') {
2277                                        inBacktickQuote = false;
2278                                        backtickQuoteStartIndex = -1;
2279                                        previousMeaningfulIndex = i;
2280                                }
2281
2282                                ++i;
2283                                continue;
2284                        }
2285
2286                        if (inBracketQuote) {
2287                                sqlFragment.append(c);
2288
2289                                if (c == ']' && i + 1 < sql.length() && sql.charAt(i + 1) == ']') {
2290                                        sqlFragment.append(']');
2291                                        i += 2;
2292                                        continue;
2293                                }
2294
2295                                if (c == ']') {
2296                                        inBracketQuote = false;
2297                                        bracketQuoteStartIndex = -1;
2298                                        previousMeaningfulIndex = i;
2299                                }
2300
2301                                ++i;
2302                                continue;
2303                        }
2304
2305                        // Not inside string/comment
2306                        if (lexicalMode == SqlLexicalMode.MYSQL && c == '#') {
2307                                sqlFragment.append(c);
2308                                ++i;
2309                                inLineComment = true;
2310                                continue;
2311                        }
2312
2313                        if (c == '-' && i + 1 < sql.length() && sql.charAt(i + 1) == '-') {
2314                                sqlFragment.append("--");
2315                                i += 2;
2316                                inLineComment = true;
2317                                continue;
2318                        }
2319
2320                        if (c == '/' && i + 1 < sql.length() && sql.charAt(i + 1) == '*') {
2321                                sqlFragment.append("/*");
2322                                i += 2;
2323                                blockCommentDepth = 1;
2324                                blockCommentStartIndex = i - 2;
2325                                continue;
2326                        }
2327
2328                        if ((c == 'U' || c == 'u') && !isIdentifierContinuation(sql, i)
2329                                        && i + 2 < sql.length() && sql.charAt(i + 1) == '&' && sql.charAt(i + 2) == '\'') {
2330                                inSingleQuote = true;
2331                                inSingleQuoteEscapesBackslash = true;
2332                                singleQuoteStartIndex = i;
2333                                sqlFragment.append(c).append("&'");
2334                                i += 3;
2335                                continue;
2336                        }
2337
2338                        if ((c == 'E' || c == 'e') && !isIdentifierContinuation(sql, i)
2339                                        && i + 1 < sql.length() && sql.charAt(i + 1) == '\'') {
2340                                inSingleQuote = true;
2341                                inSingleQuoteEscapesBackslash = true;
2342                                singleQuoteStartIndex = i;
2343                                sqlFragment.append(c).append('\'');
2344                                i += 2;
2345                                continue;
2346                        }
2347
2348                        if ((c == 'Q' || c == 'q') && !isIdentifierContinuation(sql, i)
2349                                        && i + 2 < sql.length() && sql.charAt(i + 1) == '\''
2350                                        && !Character.isWhitespace(sql.charAt(i + 2)) && sql.charAt(i + 2) != '\'') {
2351                                char openingDelimiter = sql.charAt(i + 2);
2352                                oracleQuoteClosingDelimiter = oracleQuoteClosingDelimiter(openingDelimiter);
2353                                oracleQuoteStartIndex = i;
2354                                sqlFragment.append(c).append('\'').append(openingDelimiter);
2355                                i += 3;
2356                                continue;
2357                        }
2358
2359                        if (c == '\'') {
2360                                inSingleQuote = true;
2361                                inSingleQuoteEscapesBackslash = lexicalMode == SqlLexicalMode.MYSQL;
2362                                singleQuoteStartIndex = i;
2363                                sqlFragment.append(c);
2364                                ++i;
2365                                continue;
2366                        }
2367
2368                        if (c == '"') {
2369                                inDoubleQuote = true;
2370                                doubleQuoteStartIndex = i;
2371                                sqlFragment.append(c);
2372                                ++i;
2373                                continue;
2374                        }
2375
2376                        if (c == '`') {
2377                                inBacktickQuote = true;
2378                                backtickQuoteStartIndex = i;
2379                                sqlFragment.append(c);
2380                                ++i;
2381                                continue;
2382                        }
2383
2384                        if (c == '[' && !isBracketSubscriptStart(sql, i)) {
2385                                inBracketQuote = true;
2386                                bracketQuoteStartIndex = i;
2387                                sqlFragment.append(c);
2388                                ++i;
2389                                continue;
2390                        }
2391
2392                        if (c == '$' && !isIdentifierContinuation(sql, i)) {
2393                                String delimiter = parseDollarQuoteDelimiter(sql, i);
2394
2395                                if (delimiter != null) {
2396                                        sqlFragment.append(delimiter);
2397                                        i += delimiter.length();
2398                                        dollarQuoteDelimiter = delimiter;
2399                                        dollarQuoteStartIndex = i - delimiter.length();
2400                                        continue;
2401                                }
2402                        }
2403
2404                        if (c == '?') {
2405                                if (isAllowedQuestionMarkOperator(sql, i, previousMeaningfulIndex)) {
2406                                        currentQuestionMarkOperatorIndexes.add(sqlFragment.length());
2407                                        sqlFragment.append(c);
2408                                        ++i;
2409                                        continue;
2410                                }
2411
2412                                throw new IllegalArgumentException(format("Positional parameters ('?') are not supported. Use named parameters (e.g. ':id') and %s#bind. SQL: %s",
2413                                                Query.class.getSimpleName(), sql));
2414                        }
2415
2416                        if (c == ':' && i + 1 < sql.length() && sql.charAt(i + 1) == ':') {
2417                                // Postgres type-cast operator (::), do not treat second ':' as a parameter prefix.
2418                                sqlFragment.append("::");
2419                                i += 2;
2420                                continue;
2421                        }
2422
2423                        if (c == ':' && i + 1 < sql.length() && Character.isJavaIdentifierStart(sql.charAt(i + 1))) {
2424                                int nameStartIndex = i + 1;
2425                                int nameEndIndex = nameStartIndex + 1;
2426
2427                                while (nameEndIndex < sql.length() && Character.isJavaIdentifierPart(sql.charAt(nameEndIndex)))
2428                                        ++nameEndIndex;
2429
2430                                String parameterName = sql.substring(nameStartIndex, nameEndIndex);
2431                                parameterNames.add(parameterName);
2432                                distinctParameterNames.add(parameterName);
2433                                sqlFragments.add(sqlFragment.toString());
2434                                questionMarkOperatorFragmentIndexes.add(List.copyOf(currentQuestionMarkOperatorIndexes));
2435                                currentQuestionMarkOperatorIndexes.clear();
2436                                sqlFragment.setLength(0);
2437                                i = nameEndIndex;
2438                                previousMeaningfulIndex = nameEndIndex - 1;
2439                                continue;
2440                        }
2441
2442                        sqlFragment.append(c);
2443                        if (!Character.isWhitespace(c))
2444                                previousMeaningfulIndex = i;
2445                        ++i;
2446                }
2447
2448                validateParserTerminalState(sql, inSingleQuote, singleQuoteStartIndex, inDoubleQuote, doubleQuoteStartIndex,
2449                                inBacktickQuote, backtickQuoteStartIndex, inBracketQuote, bracketQuoteStartIndex,
2450                                blockCommentDepth, blockCommentStartIndex, dollarQuoteDelimiter, dollarQuoteStartIndex,
2451                                oracleQuoteClosingDelimiter, oracleQuoteStartIndex);
2452
2453                sqlFragments.add(sqlFragment.toString());
2454                questionMarkOperatorFragmentIndexes.add(List.copyOf(currentQuestionMarkOperatorIndexes));
2455
2456                return new ParsedSql(List.copyOf(sqlFragments), List.copyOf(questionMarkOperatorFragmentIndexes),
2457                                List.copyOf(parameterNames), Set.copyOf(distinctParameterNames));
2458        }
2459
2460        private static char oracleQuoteClosingDelimiter(char openingDelimiter) {
2461                return switch (openingDelimiter) {
2462                        case '[' -> ']';
2463                        case '{' -> '}';
2464                        case '(' -> ')';
2465                        case '<' -> '>';
2466                        default -> openingDelimiter;
2467                };
2468        }
2469
2470        @Nullable
2471        private static String parseDollarQuoteDelimiter(@NonNull String sql,
2472                                                                                                                                                                                                        int startIndex) {
2473                requireNonNull(sql);
2474
2475                if (startIndex < 0 || startIndex >= sql.length())
2476                        return null;
2477
2478                if (sql.charAt(startIndex) != '$')
2479                        return null;
2480
2481                int i = startIndex + 1;
2482
2483                if (i >= sql.length())
2484                        return null;
2485
2486                char firstTagCharacter = sql.charAt(i);
2487
2488                if (firstTagCharacter == '$')
2489                        return "$$";
2490
2491                if (!isDollarQuoteTagStart(firstTagCharacter))
2492                        return null;
2493
2494                ++i;
2495
2496                while (i < sql.length()) {
2497                        char c = sql.charAt(i);
2498
2499                        if (c == '$')
2500                                return sql.substring(startIndex, i + 1);
2501
2502                        if (!isDollarQuoteTagPart(c))
2503                                return null;
2504
2505                        ++i;
2506                }
2507
2508                return null;
2509        }
2510
2511        private static boolean isDollarQuoteTagStart(char character) {
2512                return Character.isLetter(character) || character == '_';
2513        }
2514
2515        private static boolean isDollarQuoteTagPart(char character) {
2516                return Character.isLetterOrDigit(character) || character == '_';
2517        }
2518
2519        private static boolean isIdentifierContinuation(@NonNull String sql,
2520                                                                                                                                                                                                 int startIndex) {
2521                requireNonNull(sql);
2522
2523                if (startIndex <= 0)
2524                        return false;
2525
2526                return Character.isJavaIdentifierPart(sql.charAt(startIndex - 1));
2527        }
2528
2529        private static boolean isBracketSubscriptStart(@NonNull String sql,
2530                                                                                                                                                                                                 int startIndex) {
2531                requireNonNull(sql);
2532
2533                if (startIndex <= 0)
2534                        return false;
2535
2536                int previousIndex = startIndex - 1;
2537
2538                while (previousIndex >= 0 && Character.isWhitespace(sql.charAt(previousIndex)))
2539                        --previousIndex;
2540
2541                if (previousIndex < 0)
2542                        return false;
2543
2544                char previousChar = sql.charAt(previousIndex);
2545                if (Character.isJavaIdentifierPart(previousChar)) {
2546                        int tokenStartIndex = previousIndex;
2547
2548                        while (tokenStartIndex > 0 && Character.isJavaIdentifierPart(sql.charAt(tokenStartIndex - 1)))
2549                                --tokenStartIndex;
2550
2551                        String previousToken = sql.substring(tokenStartIndex, previousIndex + 1).toUpperCase(Locale.ROOT);
2552                        return !BRACKET_IDENTIFIER_CONTEXT_KEYWORDS.contains(previousToken);
2553                }
2554
2555                return previousChar == ')'
2556                                || previousChar == ']'
2557                                || previousChar == '"';
2558        }
2559
2560        private static void validateParserTerminalState(@NonNull String sql,
2561                                                                                                                                                                                                        boolean inSingleQuote,
2562                                                                                                                                                                                                        int singleQuoteStartIndex,
2563                                                                                                                                                                                                        boolean inDoubleQuote,
2564                                                                                                                                                                                                        int doubleQuoteStartIndex,
2565                                                                                                                                                                                                        boolean inBacktickQuote,
2566                                                                                                                                                                                                        int backtickQuoteStartIndex,
2567                                                                                                                                                                                                        boolean inBracketQuote,
2568                                                                                                                                                                                                        int bracketQuoteStartIndex,
2569                                                                                                                                                                                                        int blockCommentDepth,
2570                                                                                                                                                                                                        int blockCommentStartIndex,
2571                                                                                                                                                                                                                                                @Nullable String dollarQuoteDelimiter,
2572                                                                                                                                                                                                                                                int dollarQuoteStartIndex,
2573                                                                                                                                                                                                                                                @Nullable Character oracleQuoteClosingDelimiter,
2574                                                                                                                                                                                                                                                int oracleQuoteStartIndex) {
2575                requireNonNull(sql);
2576
2577                if (inSingleQuote)
2578                        throw unterminatedSqlConstructException("single-quoted string", singleQuoteStartIndex, sql);
2579                if (inDoubleQuote)
2580                        throw unterminatedSqlConstructException("double-quoted identifier", doubleQuoteStartIndex, sql);
2581                if (inBacktickQuote)
2582                        throw unterminatedSqlConstructException("backtick-quoted identifier", backtickQuoteStartIndex, sql);
2583                if (inBracketQuote)
2584                        throw unterminatedSqlConstructException("bracket-quoted identifier", bracketQuoteStartIndex, sql);
2585                if (blockCommentDepth > 0)
2586                        throw unterminatedSqlConstructException("block comment", blockCommentStartIndex, sql);
2587                if (dollarQuoteDelimiter != null)
2588                        throw unterminatedSqlConstructException(format("dollar-quoted string %s", dollarQuoteDelimiter), dollarQuoteStartIndex, sql);
2589                if (oracleQuoteClosingDelimiter != null)
2590                        throw unterminatedSqlConstructException("Oracle alternative-quoted string", oracleQuoteStartIndex, sql);
2591        }
2592
2593        @NonNull
2594        private static IllegalArgumentException unterminatedSqlConstructException(@NonNull String construct,
2595                                                                                                                                                                                                                                                                                         int startIndex,
2596                                                                                                                                                                                                                                                                                         @NonNull String sql) {
2597                requireNonNull(construct);
2598                requireNonNull(sql);
2599                return new IllegalArgumentException(format("Unterminated %s starting at index %s. SQL: %s", construct, startIndex, sql));
2600        }
2601
2602        @NonNull
2603        private static final Set<@NonNull String> QUESTION_MARK_PREFIX_KEYWORDS = Set.of(
2604                        "SELECT", "WHERE", "AND", "OR", "ON", "HAVING", "WHEN", "THEN", "ELSE", "IN",
2605                        "VALUES", "SET", "RETURNING", "USING", "LIKE", "BETWEEN", "IS", "NOT", "NULL",
2606                        "JOIN", "FROM"
2607        );
2608
2609        @NonNull
2610        private static final Set<@NonNull String> BRACKET_IDENTIFIER_CONTEXT_KEYWORDS = Set.of(
2611                        "SELECT", "FROM", "JOIN", "AS", "INTO", "UPDATE", "TABLE", "BY", "ORDER", "GROUP"
2612        );
2613
2614        @NonNull
2615        private static final Set<@NonNull String> QUESTION_MARK_SUFFIX_KEYWORDS = Set.of(
2616                        "FROM", "WHERE", "AND", "OR", "GROUP", "ORDER", "HAVING", "LIMIT", "OFFSET",
2617                        "UNION", "EXCEPT", "INTERSECT", "RETURNING", "JOIN", "ON"
2618        );
2619
2620        private static boolean isAllowedQuestionMarkOperator(@NonNull String sql,
2621                                                                                                                                                                                                                         int questionMarkIndex,
2622                                                                                                                                                                                                                         int previousMeaningfulIndex) {
2623                requireNonNull(sql);
2624
2625                int previousIndex = previousMeaningfulIndex;
2626                int nextIndex = nextNonWhitespaceIndex(sql, questionMarkIndex + 1);
2627
2628                if (previousIndex < 0 || nextIndex < 0)
2629                        return false;
2630
2631                char previousChar = sql.charAt(previousIndex);
2632                char nextChar = sql.charAt(nextIndex);
2633
2634                if (isOperatorBeforeQuestionMark(previousChar))
2635                        return false;
2636
2637                if (isTerminatorAfterQuestionMark(nextChar))
2638                        return false;
2639
2640                if (questionMarkIndex + 1 < sql.length()) {
2641                        char immediateNextChar = sql.charAt(questionMarkIndex + 1);
2642                        if (immediateNextChar == '|' || immediateNextChar == '&') {
2643                                if (questionMarkIndex + 2 < sql.length() && sql.charAt(questionMarkIndex + 2) == immediateNextChar)
2644                                        return false;
2645
2646                                String previousKeyword = keywordBefore(sql, previousIndex);
2647                                if (previousKeyword != null && QUESTION_MARK_PREFIX_KEYWORDS.contains(previousKeyword)
2648                                                && !isNamedParameterKeywordBefore(sql, previousIndex, previousKeyword))
2649                                        return false;
2650
2651                                String nextKeyword = keywordAfter(sql, nextIndex);
2652                                if (nextKeyword != null && QUESTION_MARK_SUFFIX_KEYWORDS.contains(nextKeyword))
2653                                        return false;
2654
2655                                return true;
2656                        }
2657                }
2658
2659                String previousKeyword = keywordBefore(sql, previousIndex);
2660                if (previousKeyword != null && QUESTION_MARK_PREFIX_KEYWORDS.contains(previousKeyword))
2661                        return false;
2662
2663                String nextKeyword = keywordAfter(sql, nextIndex);
2664                if (nextKeyword != null && QUESTION_MARK_SUFFIX_KEYWORDS.contains(nextKeyword))
2665                        return false;
2666
2667                return true;
2668        }
2669
2670        private static boolean isOperatorBeforeQuestionMark(char c) {
2671                return switch (c) {
2672                        case '=', '<', '>', '!', '+', '-', '*', '/', '%', ',', '(' -> true;
2673                        default -> false;
2674                };
2675        }
2676
2677        private static boolean isTerminatorAfterQuestionMark(char c) {
2678                return switch (c) {
2679                        case ')', ',', ';' -> true;
2680                        default -> false;
2681                };
2682        }
2683
2684        private static int nextNonWhitespaceIndex(@NonNull String sql,
2685                                                                                                                                                                                int startIndex) {
2686                for (int i = startIndex; i < sql.length(); i++)
2687                        if (!Character.isWhitespace(sql.charAt(i)))
2688                                return i;
2689                return -1;
2690        }
2691
2692        @Nullable
2693        private static String keywordBefore(@NonNull String sql,
2694                                                                                                                                                        int index) {
2695                char c = sql.charAt(index);
2696                if (!Character.isJavaIdentifierPart(c))
2697                        return null;
2698
2699                int endIndex = index + 1;
2700                int startIndex = index;
2701                while (startIndex >= 0 && Character.isJavaIdentifierPart(sql.charAt(startIndex)))
2702                        --startIndex;
2703
2704                return sql.substring(startIndex + 1, endIndex).toUpperCase(Locale.ROOT);
2705        }
2706
2707        private static boolean isNamedParameterKeywordBefore(@NonNull String sql,
2708                                                                                                                                                                                                                         int keywordEndIndex,
2709                                                                                                                                                                                                                         @NonNull String keyword) {
2710                requireNonNull(sql);
2711                requireNonNull(keyword);
2712
2713                int startIndex = keywordEndIndex - keyword.length() + 1;
2714                return startIndex > 0 && sql.charAt(startIndex - 1) == ':';
2715        }
2716
2717        @Nullable
2718        private static String keywordAfter(@NonNull String sql,
2719                                                                                                                                                 int index) {
2720                char c = sql.charAt(index);
2721                if (!Character.isJavaIdentifierPart(c))
2722                        return null;
2723
2724                int endIndex = index + 1;
2725                while (endIndex < sql.length() && Character.isJavaIdentifierPart(sql.charAt(endIndex)))
2726                        ++endIndex;
2727
2728                return sql.substring(index, endIndex).toUpperCase(Locale.ROOT);
2729        }
2730
2731        /**
2732         * Performs a SQL query that is expected to return 0 or 1 result rows.
2733         *
2734         * @param sql              the SQL query to execute
2735         * @param resultSetRowType the type to which {@link ResultSet} rows should be marshaled
2736         * @param parameters       {@link PreparedStatement} parameters, if any
2737         * @param <T>              the type to be returned
2738         * @return a single result (or no result)
2739         * @throws DatabaseException if > 1 row is returned
2740         */
2741        @NonNull
2742        private <T> Optional<T> queryForObject(@NonNull String sql,
2743                                                                                                                                                                 @NonNull Class<T> resultSetRowType,
2744                                                                                                                                                                 Object @Nullable ... parameters) {
2745                requireNonNull(sql);
2746                requireNonNull(resultSetRowType);
2747
2748                return queryForObject(Statement.of(generateId(), sql), resultSetRowType, parameters);
2749        }
2750
2751        /**
2752         * Performs a SQL query that is expected to return 0 or 1 result rows.
2753         *
2754         * @param statement        the SQL statement to execute
2755         * @param resultSetRowType the type to which {@link ResultSet} rows should be marshaled
2756         * @param parameters       {@link PreparedStatement} parameters, if any
2757         * @param <T>              the type to be returned
2758         * @return a single result (or no result)
2759         * @throws DatabaseException if > 1 row is returned
2760         */
2761        private <T> Optional<T> queryForObject(@NonNull Statement statement,
2762                                                                                                                                                                 @NonNull Class<T> resultSetRowType,
2763                                                                                                                                                                 Object @Nullable ... parameters) {
2764                requireNonNull(statement);
2765                requireNonNull(resultSetRowType);
2766
2767                return queryForObject(statement, resultSetRowType, null, null, parameters);
2768        }
2769
2770        private <T> Optional<T> queryForObject(@NonNull Statement statement,
2771                                                                                                                                                                 @NonNull Class<T> resultSetRowType,
2772                                                                                                                                                                 @Nullable PreparedStatementCustomizer preparedStatementCustomizer,
2773                                                                                                                                                                 StatementContext.@Nullable SpiOverrides spiOverrides,
2774                                                                                                                                                                 Object @Nullable ... parameters) {
2775                requireNonNull(statement);
2776                requireNonNull(resultSetRowType);
2777
2778                ResultHolder<Optional<T>> resultHolder = new ResultHolder<>();
2779                StatementContext<T> statementContext = StatementContext.<T>with(statement, this)
2780                                .resultSetRowType(resultSetRowType)
2781                                .parameters(parameters)
2782                                .spiOverrides(spiOverrides)
2783                                .build();
2784
2785                List<Object> parametersAsList = parameters == null ? List.of() : Arrays.asList(parameters);
2786
2787                performDatabaseOperation(statementContext, parametersAsList, preparedStatementCustomizer, (PreparedStatement preparedStatement) -> {
2788                        long startTime = nanoTime();
2789
2790                        try (ResultSet resultSet = preparedStatement.executeQuery()) {
2791                                Duration executionDuration = Duration.ofNanos(nanoTime() - startTime);
2792                                startTime = nanoTime();
2793
2794                                Optional<T> result = Optional.empty();
2795                                long rowsReturned = 0L;
2796
2797                                if (resultSet.next()) {
2798                                        rowsReturned = 1L;
2799                                        try {
2800                                                T value = resultSetMapperFor(statementContext).map(statementContext, resultSet, statementContext.getResultSetRowType().get(), getInstanceProvider()).orElse(null);
2801                                                result = Optional.ofNullable(value);
2802                                        } catch (SQLException e) {
2803                                                throw databaseExceptionWithStatementContext(statementContext,
2804                                                                format("Unable to map JDBC %s row to %s", ResultSet.class.getSimpleName(), statementContext.getResultSetRowType().get()), e);
2805                                        }
2806
2807                                        if (resultSet.next())
2808                                                throw databaseExceptionWithStatementContext(statementContext,
2809                                                                "Expected 1 row in resultset but got more than 1 instead",
2810                                                                new IllegalStateException("Expected 1 row in resultset but got more than 1 instead"));
2811                                }
2812
2813                                resultHolder.value = result;
2814                                Duration resultSetMappingDuration = Duration.ofNanos(nanoTime() - startTime);
2815                                StatementResult statementResult = getMetricsCollectorDispatcher().isEnabled()
2816                                                ? StatementResult.ofRowsReturned(rowsReturned)
2817                                                : StatementResult.empty();
2818                                return new DatabaseOperationResult(executionDuration, resultSetMappingDuration, statementResult);
2819                        }
2820                });
2821
2822                return resultHolder.value;
2823        }
2824
2825        /**
2826         * Performs a SQL query that is expected to return any number of result rows.
2827         *
2828         * @param sql              the SQL query to execute
2829         * @param resultSetRowType the type to which {@link ResultSet} rows should be marshaled
2830         * @param parameters       {@link PreparedStatement} parameters, if any
2831         * @param <T>              the type to be returned
2832         * @return a list of results
2833         */
2834        @NonNull
2835        private <T> List<@Nullable T> queryForList(@NonNull String sql,
2836                                                                                                                                                                                 @NonNull Class<T> resultSetRowType,
2837                                                                                                                                                                                 Object @Nullable ... parameters) {
2838                requireNonNull(sql);
2839                requireNonNull(resultSetRowType);
2840
2841                return queryForList(Statement.of(generateId(), sql), resultSetRowType, parameters);
2842        }
2843
2844        /**
2845         * Performs a SQL query that is expected to return any number of result rows.
2846         *
2847         * @param statement        the SQL statement to execute
2848         * @param resultSetRowType the type to which {@link ResultSet} rows should be marshaled
2849         * @param parameters       {@link PreparedStatement} parameters, if any
2850         * @param <T>              the type to be returned
2851         * @return a list of results
2852         */
2853        @NonNull
2854        private <T> List<@Nullable T> queryForList(@NonNull Statement statement,
2855                                                                                                                                                                                 @NonNull Class<T> resultSetRowType,
2856                                                                                                                                                                                 Object @Nullable ... parameters) {
2857                requireNonNull(statement);
2858                requireNonNull(resultSetRowType);
2859
2860                return queryForList(statement, resultSetRowType, null, null, parameters);
2861        }
2862
2863        private <T> List<@Nullable T> queryForList(@NonNull Statement statement,
2864                                                                                                                                                                                 @NonNull Class<T> resultSetRowType,
2865                                                                                                                                                                                 @Nullable PreparedStatementCustomizer preparedStatementCustomizer,
2866                                                                                                                                                                                 StatementContext.@Nullable SpiOverrides spiOverrides,
2867                                                                                                                                                                                 Object @Nullable ... parameters) {
2868                requireNonNull(statement);
2869                requireNonNull(resultSetRowType);
2870
2871                List<T> list = new ArrayList<>();
2872                StatementContext<T> statementContext = StatementContext.<T>with(statement, this)
2873                                .resultSetRowType(resultSetRowType)
2874                                .parameters(parameters)
2875                                .spiOverrides(spiOverrides)
2876                                .build();
2877
2878                List<Object> parametersAsList = parameters == null ? List.of() : Arrays.asList(parameters);
2879
2880                performDatabaseOperation(statementContext, parametersAsList, preparedStatementCustomizer, (PreparedStatement preparedStatement) -> {
2881                        long startTime = nanoTime();
2882
2883                        try (ResultSet resultSet = preparedStatement.executeQuery()) {
2884                                Duration executionDuration = Duration.ofNanos(nanoTime() - startTime);
2885                                startTime = nanoTime();
2886
2887                                while (resultSet.next()) {
2888                                        try {
2889                                                T listElement = resultSetMapperFor(statementContext).map(statementContext, resultSet, statementContext.getResultSetRowType().get(), getInstanceProvider()).orElse(null);
2890                                                list.add(listElement);
2891                                        } catch (SQLException e) {
2892                                                throw databaseExceptionWithStatementContext(statementContext,
2893                                                                format("Unable to map JDBC %s row to %s", ResultSet.class.getSimpleName(), statementContext.getResultSetRowType().get()), e);
2894                                        }
2895                                }
2896
2897                                Duration resultSetMappingDuration = Duration.ofNanos(nanoTime() - startTime);
2898                                StatementResult statementResult = getMetricsCollectorDispatcher().isEnabled()
2899                                                ? StatementResult.ofRowsReturned((long) list.size())
2900                                                : StatementResult.empty();
2901                                return new DatabaseOperationResult(executionDuration, resultSetMappingDuration, statementResult);
2902                        }
2903                });
2904
2905                return list;
2906        }
2907
2908        @Nullable
2909        private <T, R> R queryForStream(@NonNull Statement statement,
2910                                                                                                                                        @NonNull Class<T> resultSetRowType,
2911                                                                                                                                        @Nullable PreparedStatementCustomizer preparedStatementCustomizer,
2912                                                                                                                                        boolean queryFetchSizeConfigured,
2913                                                                                                                                        @NonNull Function<Stream<@Nullable T>, R> streamFunction,
2914                                                                                                                                        StatementContext.@Nullable SpiOverrides spiOverrides,
2915                                                                                                                                        Object @Nullable ... parameters) {
2916                requireNonNull(statement);
2917                requireNonNull(resultSetRowType);
2918                requireNonNull(streamFunction);
2919
2920                StatementContext<T> statementContext = StatementContext.<T>with(statement, this)
2921                                .resultSetRowType(resultSetRowType)
2922                                .parameters(parameters)
2923                                .spiOverrides(spiOverrides)
2924                                .build();
2925
2926                List<Object> parametersAsList = parameters == null ? List.of() : Arrays.asList(parameters);
2927                StreamingResultSet<T> iterator = new StreamingResultSet<>(this, statementContext, parametersAsList,
2928                                preparedStatementCustomizer, queryFetchSizeConfigured);
2929
2930                try {
2931                        try (Stream<@Nullable T> stream = StreamSupport.stream(Spliterators.spliteratorUnknownSize(iterator, Spliterator.ORDERED), false)
2932                                        .onClose(iterator::close)) {
2933                                try {
2934                                        return streamFunction.apply(stream);
2935                                } catch (Throwable throwable) {
2936                                        iterator.callbackFailed(throwable);
2937                                        if (throwable instanceof RuntimeException runtimeException)
2938                                                throw runtimeException;
2939                                        if (throwable instanceof Error error)
2940                                                throw error;
2941                                        throw new RuntimeException(throwable);
2942                                }
2943                        }
2944                } finally {
2945                        iterator.emitTerminalMetrics();
2946                }
2947        }
2948
2949        /**
2950         * Executes a SQL Data Manipulation Language (DML) statement, such as {@code INSERT}, {@code UPDATE}, or {@code DELETE};
2951         * or a SQL statement that returns nothing, such as a DDL statement.
2952         *
2953         * @param sql        the SQL to execute
2954         * @param parameters {@link PreparedStatement} parameters, if any
2955         * @return the number of rows affected by the SQL statement
2956         */
2957        @NonNull
2958        private Long execute(@NonNull String sql,
2959                                                                                         Object @Nullable ... parameters) {
2960                requireNonNull(sql);
2961                return execute(Statement.of(generateId(), sql), parameters);
2962        }
2963
2964        /**
2965         * Executes a SQL Data Manipulation Language (DML) statement, such as {@code INSERT}, {@code UPDATE}, or {@code DELETE};
2966         * or a SQL statement that returns nothing, such as a DDL statement.
2967         *
2968         * @param statement  the SQL statement to execute
2969         * @param parameters {@link PreparedStatement} parameters, if any
2970         * @return the number of rows affected by the SQL statement
2971         */
2972        @NonNull
2973        private Long execute(@NonNull Statement statement,
2974                                                                                         Object @Nullable ... parameters) {
2975                requireNonNull(statement);
2976
2977                return execute(statement, null, null, parameters);
2978        }
2979
2980        private Long execute(@NonNull Statement statement,
2981                                                                                         @Nullable PreparedStatementCustomizer preparedStatementCustomizer,
2982                                                                                         StatementContext.@Nullable SpiOverrides spiOverrides,
2983                                                                                         Object @Nullable ... parameters) {
2984                requireNonNull(statement);
2985
2986                ResultHolder<Long> resultHolder = new ResultHolder<>();
2987                StatementContext<Void> statementContext = StatementContext.with(statement, this)
2988                                .parameters(parameters)
2989                                .spiOverrides(spiOverrides)
2990                                .build();
2991
2992                List<Object> parametersAsList = parameters == null ? List.of() : Arrays.asList(parameters);
2993
2994                performDatabaseOperation(statementContext, parametersAsList, preparedStatementCustomizer, (PreparedStatement preparedStatement) -> {
2995                        long startTime = nanoTime();
2996                        resultHolder.value = executeUpdate(preparedStatement);
2997
2998                        Duration executionDuration = Duration.ofNanos(nanoTime() - startTime);
2999                        StatementResult statementResult = getMetricsCollectorDispatcher().isEnabled()
3000                                        ? StatementResult.ofRowsAffected(resultHolder.value)
3001                                        : StatementResult.empty();
3002                        return new DatabaseOperationResult(executionDuration, null, statementResult);
3003                });
3004
3005                return resultHolder.value;
3006        }
3007
3008        @NonNull
3009        private Long executeUpdate(@NonNull PreparedStatement preparedStatement) throws SQLException {
3010                requireNonNull(preparedStatement);
3011
3012                DatabaseOperationSupportStatus executeLargeUpdateSupported = getExecuteLargeUpdateSupported();
3013
3014                // Use the appropriate "large" value if we know it.
3015                // If we don't know it, detect it and store it.
3016                if (executeLargeUpdateSupported == DatabaseOperationSupportStatus.YES)
3017                        return preparedStatement.executeLargeUpdate();
3018
3019                if (executeLargeUpdateSupported == DatabaseOperationSupportStatus.NO)
3020                        return (long) preparedStatement.executeUpdate();
3021
3022                // If the driver doesn't support executeLargeUpdate, then UnsupportedOperationException is thrown.
3023                try {
3024                        Long result = preparedStatement.executeLargeUpdate();
3025                        setExecuteLargeUpdateSupported(DatabaseOperationSupportStatus.YES);
3026                        return result;
3027                } catch (SQLFeatureNotSupportedException | UnsupportedOperationException | AbstractMethodError e) {
3028                        setExecuteLargeUpdateSupported(DatabaseOperationSupportStatus.NO);
3029                        return (long) preparedStatement.executeUpdate();
3030                } catch (SQLException e) {
3031                        if (isUnsupportedSqlFeature(e)) {
3032                                setExecuteLargeUpdateSupported(DatabaseOperationSupportStatus.NO);
3033                                return (long) preparedStatement.executeUpdate();
3034                        }
3035
3036                        throw e;
3037                }
3038        }
3039
3040        @NonNull
3041        private <T> Optional<T> executeReturningGeneratedKey(@NonNull Statement statement,
3042                                                                                                                                                                                                                         @NonNull Class<T> resultSetRowType,
3043                                                                                                                                                                                                                         @Nullable PreparedStatementCustomizer preparedStatementCustomizer,
3044                                                                                                                                                                                                                         @Nullable String @Nullable [] keyColumnNames,
3045                                                                                                                                                                                                                         StatementContext.@Nullable SpiOverrides spiOverrides,
3046                                                                                                                                                                                                                         Object @Nullable ... parameters) {
3047                requireNonNull(statement);
3048                requireNonNull(resultSetRowType);
3049
3050                ResultHolder<Optional<T>> resultHolder = new ResultHolder<>();
3051                StatementContext<T> statementContext = StatementContext.<T>with(statement, this)
3052                                .resultSetRowType(resultSetRowType)
3053                                .parameters(parameters)
3054                                .spiOverrides(spiOverrides)
3055                                .build();
3056
3057                List<Object> parametersAsList = parameters == null ? List.of() : Arrays.asList(parameters);
3058                String[] requestedKeyColumnNames = copyGeneratedKeyColumnNames(keyColumnNames);
3059
3060                performDatabaseOperation(statementContext, parametersAsList, preparedStatementCustomizer, (PreparedStatement preparedStatement) -> {
3061                        long startTime = nanoTime();
3062                        Long rowsAffected = executeUpdate(preparedStatement);
3063
3064                        try (ResultSet resultSet = preparedStatement.getGeneratedKeys()) {
3065                                Duration executionDuration = Duration.ofNanos(nanoTime() - startTime);
3066                                startTime = nanoTime();
3067
3068                                Optional<T> result = Optional.empty();
3069                                long rowsReturned = 0L;
3070
3071                                if (resultSet.next()) {
3072                                        rowsReturned = 1L;
3073                                        try {
3074                                                T value = resultSetMapperFor(statementContext).map(statementContext, resultSet, statementContext.getResultSetRowType().get(), getInstanceProvider()).orElse(null);
3075                                                result = Optional.ofNullable(value);
3076                                        } catch (SQLException e) {
3077                                                throw databaseExceptionWithStatementContext(statementContext,
3078                                                                format("Unable to map JDBC generated-key row to %s", statementContext.getResultSetRowType().get()), e);
3079                                        }
3080
3081                                        if (resultSet.next())
3082                                                throw databaseExceptionWithStatementContext(statementContext,
3083                                                                "Expected 1 generated-key row but got more than 1 instead",
3084                                                                new IllegalStateException("Expected 1 generated-key row but got more than 1 instead"));
3085                                }
3086
3087                                resultHolder.value = result;
3088                                Duration resultSetMappingDuration = Duration.ofNanos(nanoTime() - startTime);
3089                                StatementResult statementResult = getMetricsCollectorDispatcher().isEnabled()
3090                                                ? new StatementResult(rowsReturned, rowsAffected)
3091                                                : StatementResult.empty();
3092                                return new DatabaseOperationResult(executionDuration, resultSetMappingDuration, statementResult);
3093                        }
3094                }, generatedKeysPreparedStatementFactory(requestedKeyColumnNames));
3095
3096                return resultHolder.value;
3097        }
3098
3099        @NonNull
3100        private <T> List<@Nullable T> executeReturningGeneratedKeys(@NonNull Statement statement,
3101                                                                                                                                                                                                                                                 @NonNull Class<T> resultSetRowType,
3102                                                                                                                                                                                                                                                 @Nullable PreparedStatementCustomizer preparedStatementCustomizer,
3103                                                                                                                                                                                                                                                 @Nullable String @Nullable [] keyColumnNames,
3104                                                                                                                                                                                                                                                 StatementContext.@Nullable SpiOverrides spiOverrides,
3105                                                                                                                                                                                                                                                 Object @Nullable ... parameters) {
3106                requireNonNull(statement);
3107                requireNonNull(resultSetRowType);
3108
3109                List<T> list = new ArrayList<>();
3110                StatementContext<T> statementContext = StatementContext.<T>with(statement, this)
3111                                .resultSetRowType(resultSetRowType)
3112                                .parameters(parameters)
3113                                .spiOverrides(spiOverrides)
3114                                .build();
3115
3116                List<Object> parametersAsList = parameters == null ? List.of() : Arrays.asList(parameters);
3117                String[] requestedKeyColumnNames = copyGeneratedKeyColumnNames(keyColumnNames);
3118
3119                performDatabaseOperation(statementContext, parametersAsList, preparedStatementCustomizer, (PreparedStatement preparedStatement) -> {
3120                        long startTime = nanoTime();
3121                        Long rowsAffected = executeUpdate(preparedStatement);
3122
3123                        try (ResultSet resultSet = preparedStatement.getGeneratedKeys()) {
3124                                Duration executionDuration = Duration.ofNanos(nanoTime() - startTime);
3125                                startTime = nanoTime();
3126
3127                                while (resultSet.next()) {
3128                                        try {
3129                                                T listElement = resultSetMapperFor(statementContext).map(statementContext, resultSet, statementContext.getResultSetRowType().get(), getInstanceProvider()).orElse(null);
3130                                                list.add(listElement);
3131                                        } catch (SQLException e) {
3132                                                throw databaseExceptionWithStatementContext(statementContext,
3133                                                                format("Unable to map JDBC generated-key row to %s", statementContext.getResultSetRowType().get()), e);
3134                                        }
3135                                }
3136
3137                                Duration resultSetMappingDuration = Duration.ofNanos(nanoTime() - startTime);
3138                                StatementResult statementResult = getMetricsCollectorDispatcher().isEnabled()
3139                                                ? new StatementResult((long) list.size(), rowsAffected)
3140                                                : StatementResult.empty();
3141                                return new DatabaseOperationResult(executionDuration, resultSetMappingDuration, statementResult);
3142                        }
3143                }, generatedKeysPreparedStatementFactory(requestedKeyColumnNames));
3144
3145                return list;
3146        }
3147
3148        @NonNull
3149        private String[] copyGeneratedKeyColumnNames(@Nullable String @Nullable [] keyColumnNames) {
3150                if (keyColumnNames == null || keyColumnNames.length == 0)
3151                        return new String[0];
3152
3153                String[] copy = Arrays.copyOf(keyColumnNames, keyColumnNames.length);
3154
3155                for (String keyColumnName : copy)
3156                        requireNonNull(keyColumnName);
3157
3158                return copy;
3159        }
3160
3161        @NonNull
3162        private PreparedStatementFactory generatedKeysPreparedStatementFactory(@NonNull String @NonNull [] keyColumnNames) {
3163                requireNonNull(keyColumnNames);
3164
3165                return (connection, statementContext) -> getDatabaseDialect(connection)
3166                                .prepareGeneratedKeysStatement(connection, statementContext, keyColumnNames);
3167        }
3168
3169        /**
3170         * Executes a SQL Data Manipulation Language (DML) statement, such as {@code INSERT}, {@code UPDATE}, or {@code DELETE},
3171         * which returns 0 or 1 rows with database-native syntax such as PostgreSQL/SQLite {@code RETURNING},
3172         * MariaDB {@code INSERT ... RETURNING},
3173         * or SQL Server {@code OUTPUT}.
3174         *
3175         * @param sql              the SQL query to execute
3176         * @param resultSetRowType the type to which the {@link ResultSet} row should be marshaled
3177         * @param parameters       {@link PreparedStatement} parameters, if any
3178         * @param <T>              the type to be returned
3179         * @return a single result (or no result)
3180         * @throws DatabaseException if > 1 row is returned
3181         */
3182        @NonNull
3183        private <T> Optional<T> executeForObject(@NonNull String sql,
3184                                                                                                                                                                         @NonNull Class<T> resultSetRowType,
3185                                                                                                                                                                         Object @Nullable ... parameters) {
3186                requireNonNull(sql);
3187                requireNonNull(resultSetRowType);
3188
3189                return executeForObject(Statement.of(generateId(), sql), resultSetRowType, parameters);
3190        }
3191
3192        /**
3193         * Executes a SQL Data Manipulation Language (DML) statement, such as {@code INSERT}, {@code UPDATE}, or {@code DELETE},
3194         * which returns 0 or 1 rows with database-native syntax such as PostgreSQL/SQLite {@code RETURNING},
3195         * MariaDB {@code INSERT ... RETURNING},
3196         * or SQL Server {@code OUTPUT}.
3197         *
3198         * @param statement        the SQL statement to execute
3199         * @param resultSetRowType the type to which {@link ResultSet} rows should be marshaled
3200         * @param parameters       {@link PreparedStatement} parameters, if any
3201         * @param <T>              the type to be returned
3202         * @return a single result (or no result)
3203         * @throws DatabaseException if > 1 row is returned
3204         */
3205        private <T> Optional<T> executeForObject(@NonNull Statement statement,
3206                                                                                                                                                                         @NonNull Class<T> resultSetRowType,
3207                                                                                                                                                                         Object @Nullable ... parameters) {
3208                requireNonNull(statement);
3209                requireNonNull(resultSetRowType);
3210
3211                return executeForObject(statement, resultSetRowType, null, null, parameters);
3212        }
3213
3214        private <T> Optional<T> executeForObject(@NonNull Statement statement,
3215                                                                                                                                                                         @NonNull Class<T> resultSetRowType,
3216                                                                                                                                                                         @Nullable PreparedStatementCustomizer preparedStatementCustomizer,
3217                                                                                                                                                                         StatementContext.@Nullable SpiOverrides spiOverrides,
3218                                                                                                                                                                         Object @Nullable ... parameters) {
3219                requireNonNull(statement);
3220                requireNonNull(resultSetRowType);
3221
3222                // Ultimately we just delegate to queryForObject.
3223                // Having `executeForList` is to allow for users to explicitly express intent
3224                // and make static analysis of code easier (e.g. maybe you'd like to hook all of your "execute" statements for
3225                // logging, or delegation to a writable master as opposed to a read replica)
3226                return queryForObject(statement, resultSetRowType, preparedStatementCustomizer, spiOverrides, parameters);
3227        }
3228
3229        /**
3230         * Executes a SQL Data Manipulation Language (DML) statement, such as {@code INSERT}, {@code UPDATE}, or {@code DELETE},
3231         * which returns any number of rows with database-native syntax such as PostgreSQL/SQLite {@code RETURNING},
3232         * MariaDB {@code INSERT ... RETURNING},
3233         * or SQL Server {@code OUTPUT}.
3234         *
3235         * @param sql              the SQL to execute
3236         * @param resultSetRowType the type to which {@link ResultSet} rows should be marshaled
3237         * @param parameters       {@link PreparedStatement} parameters, if any
3238         * @param <T>              the type to be returned
3239         * @return a list of results
3240         */
3241        @NonNull
3242        private <T> List<@Nullable T> executeForList(@NonNull String sql,
3243                                                                                                                                                                                         @NonNull Class<T> resultSetRowType,
3244                                                                                                                                                                                         Object @Nullable ... parameters) {
3245                requireNonNull(sql);
3246                requireNonNull(resultSetRowType);
3247
3248                return executeForList(Statement.of(generateId(), sql), resultSetRowType, parameters);
3249        }
3250
3251        /**
3252         * Executes a SQL Data Manipulation Language (DML) statement, such as {@code INSERT}, {@code UPDATE}, or {@code DELETE},
3253         * which returns any number of rows with database-native syntax such as PostgreSQL/SQLite {@code RETURNING},
3254         * MariaDB {@code INSERT ... RETURNING},
3255         * or SQL Server {@code OUTPUT}.
3256         *
3257         * @param statement        the SQL statement to execute
3258         * @param resultSetRowType the type to which {@link ResultSet} rows should be marshaled
3259         * @param parameters       {@link PreparedStatement} parameters, if any
3260         * @param <T>              the type to be returned
3261         * @return a list of results
3262         */
3263        @NonNull
3264        private <T> List<@Nullable T> executeForList(@NonNull Statement statement,
3265                                                                                                                                                                                         @NonNull Class<T> resultSetRowType,
3266                                                                                                                                                                                         Object @Nullable ... parameters) {
3267                requireNonNull(statement);
3268                requireNonNull(resultSetRowType);
3269
3270                return executeForList(statement, resultSetRowType, null, null, parameters);
3271        }
3272
3273        private <T> List<@Nullable T> executeForList(@NonNull Statement statement,
3274                                                                                                                                                                                         @NonNull Class<T> resultSetRowType,
3275                                                                                                                                                                                         @Nullable PreparedStatementCustomizer preparedStatementCustomizer,
3276                                                                                                                                                                                         StatementContext.@Nullable SpiOverrides spiOverrides,
3277                                                                                                                                                                                         Object @Nullable ... parameters) {
3278                requireNonNull(statement);
3279                requireNonNull(resultSetRowType);
3280
3281                // Ultimately we just delegate to queryForList.
3282                // Having `executeForList` is to allow for users to explicitly express intent
3283                // and make static analysis of code easier (e.g. maybe you'd like to hook all of your "execute" statements for
3284                // logging, or delegation to a writable master as opposed to a read replica)
3285                return queryForList(statement, resultSetRowType, preparedStatementCustomizer, spiOverrides, parameters);
3286        }
3287
3288        /**
3289         * Executes a SQL Data Manipulation Language (DML) statement, such as {@code INSERT}, {@code UPDATE}, or {@code DELETE}
3290         * in "batch" over a set of parameter groups.
3291         * <p>
3292         * Useful for bulk-inserting or updating large amounts of data.
3293         *
3294         * @param sql             the SQL to execute
3295         * @param parameterGroups Groups of {@link PreparedStatement} parameters
3296         * @return the number of rows affected by the SQL statement per-group
3297         */
3298        @NonNull
3299        private List<Long> executeBatch(@NonNull String sql,
3300                                                                                                                                        @NonNull List<List<Object>> parameterGroups) {
3301                requireNonNull(sql);
3302                requireNonNull(parameterGroups);
3303
3304                return executeBatch(Statement.of(generateId(), sql), parameterGroups);
3305        }
3306
3307        /**
3308         * Executes a SQL Data Manipulation Language (DML) statement, such as {@code INSERT}, {@code UPDATE}, or {@code DELETE}
3309         * in "batch" over a set of parameter groups.
3310         * <p>
3311         * Useful for bulk-inserting or updating large amounts of data.
3312         *
3313         * @param statement       the SQL statement to execute
3314         * @param parameterGroups Groups of {@link PreparedStatement} parameters
3315         * @return the number of rows affected by the SQL statement per-group
3316         */
3317        @NonNull
3318        private List<Long> executeBatch(@NonNull Statement statement,
3319                                                                                                                                        @NonNull List<List<Object>> parameterGroups) {
3320                requireNonNull(statement);
3321                requireNonNull(parameterGroups);
3322
3323                return executeBatch(statement, parameterGroups, null, null, null);
3324        }
3325
3326        private List<Long> executeBatch(@NonNull Statement statement,
3327                                                                                                                                        @NonNull List<List<Object>> parameterGroups,
3328                                                                                                                                        @Nullable PreparedStatementCustomizer preparedStatementCustomizer,
3329                                                                                                                                        @Nullable Integer batchChunkSize,
3330                                                                                                                                        StatementContext.@Nullable SpiOverrides spiOverrides) {
3331                requireNonNull(statement);
3332                requireNonNull(parameterGroups);
3333                if (parameterGroups.isEmpty())
3334                        return List.of();
3335
3336                Integer expectedParameterCount = null;
3337
3338                for (int i = 0; i < parameterGroups.size(); i++) {
3339                        List<Object> parameterGroup = parameterGroups.get(i);
3340
3341                        if (parameterGroup == null)
3342                                throw new IllegalArgumentException(format("Parameter group at index %s is null", i));
3343
3344                        int parameterCount = parameterGroup.size();
3345                        if (expectedParameterCount == null) {
3346                                expectedParameterCount = parameterCount;
3347                        } else if (parameterCount != expectedParameterCount) {
3348                                throw new IllegalArgumentException(format(
3349                                                "Inconsistent parameter group size at index %s: expected %s but found %s",
3350                                                i, expectedParameterCount, parameterCount));
3351                        }
3352                }
3353
3354                ResultHolder<List<Long>> resultHolder = new ResultHolder<>();
3355                StatementContext<List<Long>> statementContext = StatementContext.with(statement, this)
3356                                .parameters((List) parameterGroups)
3357                                .resultSetRowType(List.class)
3358                                .batchParameterGroups(true)
3359                                .spiOverrides(spiOverrides)
3360                                .build();
3361
3362                if (batchChunkSize == null || batchChunkSize >= parameterGroups.size()) {
3363                        performDatabaseOperation(statementContext, (preparedStatement) -> {
3364                                applyPreparedStatementCustomizer(statementContext, preparedStatement, preparedStatementCustomizer);
3365
3366                                for (List<Object> parameterGroup : parameterGroups) {
3367                                        if (parameterGroup.size() > 0)
3368                                                performPreparedStatementBinding(statementContext, preparedStatement, parameterGroup);
3369
3370                                        preparedStatement.addBatch();
3371                                }
3372                        }, (PreparedStatement preparedStatement) -> {
3373                                long startTime = nanoTime();
3374                                List<Long> result = executePreparedStatementBatch(preparedStatement);
3375
3376                                resultHolder.value = result;
3377                                return batchDatabaseOperationResult(startTime, result);
3378                        }, parameterGroups.size());
3379                } else {
3380                        int effectiveBatchChunkSize = batchChunkSize;
3381
3382                        performDatabaseOperation(statementContext, (preparedStatement) -> {
3383                                applyPreparedStatementCustomizer(statementContext, preparedStatement, preparedStatementCustomizer);
3384                        }, (PreparedStatement preparedStatement) -> {
3385                                long startTime = nanoTime();
3386                                int currentBatchSize = 0;
3387                                List<Long> result = new ArrayList<>(parameterGroups.size());
3388
3389                                for (List<Object> parameterGroup : parameterGroups) {
3390                                        if (parameterGroup.size() > 0)
3391                                                performPreparedStatementBinding(statementContext, preparedStatement, parameterGroup);
3392
3393                                        preparedStatement.addBatch();
3394                                        ++currentBatchSize;
3395
3396                                        if (currentBatchSize == effectiveBatchChunkSize) {
3397                                                result.addAll(executePreparedStatementBatch(preparedStatement));
3398                                                preparedStatement.clearBatch();
3399                                                currentBatchSize = 0;
3400                                        }
3401                                }
3402
3403                                if (currentBatchSize > 0) {
3404                                        result.addAll(executePreparedStatementBatch(preparedStatement));
3405                                        preparedStatement.clearBatch();
3406                                }
3407
3408                                resultHolder.value = result;
3409                                return batchDatabaseOperationResult(startTime, result);
3410                        }, parameterGroups.size());
3411                }
3412
3413                return resultHolder.value;
3414        }
3415
3416        @NonNull
3417        private DatabaseOperationResult batchDatabaseOperationResult(long startTime,
3418                                                                                                                                                                                                                                                        @NonNull List<Long> result) {
3419                requireNonNull(result);
3420
3421                Duration executionDuration = Duration.ofNanos(nanoTime() - startTime);
3422                StatementResult statementResult = StatementResult.empty();
3423                if (getMetricsCollectorDispatcher().isEnabled()) {
3424                        Long rowsAffected = sumBatchUpdateCounts(result);
3425                        statementResult = rowsAffected == null ? StatementResult.empty() : StatementResult.ofRowsAffected(rowsAffected);
3426                }
3427
3428                return new DatabaseOperationResult(executionDuration, null, statementResult);
3429        }
3430
3431        @NonNull
3432        private List<Long> executePreparedStatementBatch(@NonNull PreparedStatement preparedStatement) throws SQLException {
3433                requireNonNull(preparedStatement);
3434
3435                DatabaseOperationSupportStatus executeLargeBatchSupported = getExecuteLargeBatchSupported();
3436
3437                // Use the appropriate "large" value if we know it.
3438                // If we don't know it, detect it and store it.
3439                if (executeLargeBatchSupported == DatabaseOperationSupportStatus.YES) {
3440                        long[] resultArray = preparedStatement.executeLargeBatch();
3441                        return Arrays.stream(resultArray).boxed().collect(Collectors.toList());
3442                }
3443                if (executeLargeBatchSupported == DatabaseOperationSupportStatus.NO) {
3444                        int[] resultArray = preparedStatement.executeBatch();
3445                        return Arrays.stream(resultArray).asLongStream().boxed().collect(Collectors.toList());
3446                }
3447
3448                // If the driver doesn't support executeLargeBatch, then UnsupportedOperationException is thrown.
3449                try {
3450                        long[] resultArray = preparedStatement.executeLargeBatch();
3451                        setExecuteLargeBatchSupported(DatabaseOperationSupportStatus.YES);
3452                        return Arrays.stream(resultArray).boxed().collect(Collectors.toList());
3453                } catch (SQLFeatureNotSupportedException | UnsupportedOperationException | AbstractMethodError e) {
3454                        setExecuteLargeBatchSupported(DatabaseOperationSupportStatus.NO);
3455                        int[] resultArray = preparedStatement.executeBatch();
3456                        return Arrays.stream(resultArray).asLongStream().boxed().collect(Collectors.toList());
3457                } catch (SQLException e) {
3458                        if (!isUnsupportedSqlFeature(e))
3459                                throw e;
3460
3461                        setExecuteLargeBatchSupported(DatabaseOperationSupportStatus.NO);
3462                        int[] resultArray = preparedStatement.executeBatch();
3463                        return Arrays.stream(resultArray).asLongStream().boxed().collect(Collectors.toList());
3464                }
3465        }
3466
3467        /**
3468         * Exposes a temporary handle to JDBC {@link DatabaseMetaData}, which provides comprehensive vendor-specific information about this database as a whole.
3469         * <p>
3470         * This method acquires {@link DatabaseMetaData} on its own newly-borrowed connection, which it manages internally.
3471         * <p>
3472         * It does <strong>not</strong> participate in the active transaction, if one exists.
3473         * <p>
3474         * The connection is closed as soon as {@link DatabaseMetaDataReader#read(DatabaseMetaData)} completes.
3475         * <p>
3476         * 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.
3477         */
3478        public void readDatabaseMetaData(@NonNull DatabaseMetaDataReader databaseMetaDataReader) {
3479                requireNonNull(databaseMetaDataReader);
3480
3481                performRawConnectionOperation((connection -> {
3482                        databaseMetaDataReader.read(connection.getMetaData());
3483                        return Optional.empty();
3484                }), false);
3485        }
3486
3487        /**
3488         * Performs raw JDBC work with a Pyranid-managed {@link Connection}.
3489         * <p>
3490         * If called inside a Pyranid transaction, this operation uses the transaction's connection and participates in that
3491         * transaction. Otherwise, Pyranid borrows a connection for the duration of the callback and closes it afterwards.
3492         * <p>
3493         * The {@link Connection} passed to {@code rawConnectionOperation} is a guarded handle. Normal JDBC operations are
3494         * delegated to the underlying driver connection, but lifecycle, transaction-management, and connection-wide state
3495         * methods such as
3496         * {@link Connection#close()}, {@link Connection#commit()}, {@link Connection#rollback()},
3497         * {@link Connection#setAutoCommit(boolean)}, {@link Connection#setCatalog(String)}, {@link Connection#setSchema(String)}, and
3498         * {@link Connection#setNetworkTimeout(java.util.concurrent.Executor, int)} throw {@link IllegalStateException}. Use
3499         * Pyranid transaction APIs instead.
3500         * {@link Connection#unwrap(Class)} may return a guarded, callback-scoped proxy for a vendor interface, but never a
3501         * castable physical {@link Connection}; the proxy blocks lifecycle methods and expires with the callback.
3502         * JDBC objects created from this handle are also guarded: {@link java.sql.Statement#getConnection()} and
3503         * {@link java.sql.DatabaseMetaData#getConnection()} return the Pyranid-managed handle, and
3504         * {@link ResultSet#getStatement()} returns a guarded statement. Guarded statements, resultsets, and metadata refuse
3505         * driver-specific {@code unwrap(...)} calls that could expose the driver's underlying connection.
3506         * <p>
3507         * The connection handle is valid only for the duration of the callback. Do not close it, retain it, or use it after this
3508         * method returns.
3509         *
3510         * @param rawConnectionOperation the raw JDBC operation to perform
3511         * @param <T>                    the type to be returned
3512         * @return the operation result
3513         * @throws DatabaseException if connection acquisition, callback execution, or cleanup fails
3514         * @since 4.2.0
3515         */
3516        @NonNull
3517        public <T> Optional<T> useRawConnection(@NonNull RawConnectionOperation<T> rawConnectionOperation) {
3518                requireNonNull(rawConnectionOperation);
3519
3520                return performRawConnectionOperation(connection -> {
3521                        PyranidRawConnection rawConnection = new PyranidRawConnection(connection);
3522
3523                        try {
3524                                Optional<T> result = rawConnectionOperation.perform(rawConnection);
3525                                return result == null ? Optional.empty() : result;
3526                        } finally {
3527                                rawConnection.release();
3528                        }
3529                }, true);
3530        }
3531
3532        /**
3533         * Performs an operation with one callback-scoped database-notification listener session.
3534         * <p>
3535         * This method is synchronous and blocking. It acquires at most one listener connection from this
3536         * {@code Database}'s configured {@link DataSource}, registers every requested channel, invokes {@code operation}
3537         * at most once, expires the supplied {@link NotificationSession}, and completes cleanup before returning or
3538         * throwing. It never reconnects.
3539         * <p>
3540         * The configured source must preserve one physical backend session for the entire checkout. For PostgreSQL,
3541         * direct connections and session pooling are suitable. Using PgBouncer transaction or statement pooling as the
3542         * listener source is unsupported: registration can appear to succeed before backend-session affinity is lost and
3543         * notification delivery silently stops. Pyranid does not inspect or validate proxy topology. Applications whose
3544         * ordinary source cannot provide the required affinity should construct a separate {@code Database} over a suitable
3545         * listener source and invoke this method on that instance.
3546         * <p>
3547         * Notifications are lossy hints. Durable applications should normally reconcile authoritative state as the first
3548         * callback action. The operation may use ordinary database methods, but those methods acquire or select their
3549         * connection normally and never reuse the listener connection.
3550         * <p>
3551         * A terminal receive failure is retained by the session and rethrown after cleanup even if {@code operation} catches
3552         * it and returns. A retained transport {@link Error} propagates as that exact, unwrapped instance. If the callback
3553         * instead throws a distinct {@code Error}, that callback error remains primary and the retained transport failure is
3554         * suppressed beneath it.
3555         *
3556         * @param channels  fixed, nonempty set of nonblank channels to register
3557         * @param operation operation to invoke after every channel has been registered
3558         * @throws NullPointerException if {@code channels}, a channel, or {@code operation} is null
3559         * @throws IllegalArgumentException if the set is empty or a channel violates common or backend-specific limits
3560         * @throws IllegalStateException if any Pyranid transaction is active on the calling thread
3561         * @throws InterruptedException if cooperative interruption wins after any required cleanup
3562         * @throws UnsupportedOperationException if the database dialect or runtime driver cannot receive notifications
3563         * @throws DatabaseException if connection acquisition, setup, receive, callback execution, or cleanup fails
3564         * @since 4.6.0
3565         */
3566        public void withNotificationSession(@NonNull Set<@NonNull String> channels,
3567                                                                                                                                                        @NonNull NotificationSessionOperation operation)
3568                        throws InterruptedException {
3569                requireNonNull(channels);
3570                requireNonNull(operation);
3571
3572                if (channels.isEmpty())
3573                        throw new IllegalArgumentException("channels must not be empty");
3574
3575                Set<String> validatedChannels = new LinkedHashSet<>(channels.size());
3576
3577                for (String channel : channels) {
3578                        Notification.validateChannel(channel);
3579                        validatedChannels.add(channel);
3580                }
3581
3582                if (hasAmbientTransaction())
3583                        throw new IllegalStateException("Notification sessions are not permitted inside a Pyranid transaction");
3584
3585                if (Thread.interrupted())
3586                        throw new InterruptedException();
3587
3588                DatabaseType databaseType = getDatabaseType();
3589                DatabaseNotificationSupport notificationSupport = getDatabaseDialect().notificationSupport();
3590
3591                for (String channel : validatedChannels)
3592                        notificationSupport.validateChannel(channel);
3593
3594                if (Thread.interrupted())
3595                        throw new InterruptedException();
3596
3597                performNotificationSession(Set.copyOf(validatedChannels), operation, databaseType, notificationSupport);
3598        }
3599
3600        /**
3601         * Performs an operation with one callback-scoped database-notification listener session for a single channel.
3602         * <p>
3603         * This is the single-channel convenience form of
3604         * {@link #withNotificationSession(Set, NotificationSessionOperation)}. The listener connection comes from this
3605         * {@code Database}'s configured {@link DataSource}, which must preserve physical backend-session affinity for the
3606         * entire checkout. For PostgreSQL, using PgBouncer transaction or statement pooling as the listener source is
3607         * unsupported: registration can appear to succeed before affinity is lost and delivery silently stops. Pyranid does
3608         * not inspect or validate proxy topology.
3609         * <p>
3610         * A terminal receive failure is retained and rethrown after cleanup even if {@code operation} catches it and returns.
3611         * A retained transport {@link Error} propagates as the exact, unwrapped instance unless a distinct callback
3612         * {@code Error} takes precedence as described by the set-based overload.
3613         *
3614         * @param channel   nonblank channel to register
3615         * @param operation operation to invoke after the channel has been registered
3616         * @throws NullPointerException if {@code channel} or {@code operation} is null
3617         * @throws IllegalArgumentException if the channel violates common or backend-specific limits
3618         * @throws IllegalStateException if any Pyranid transaction is active on the calling thread
3619         * @throws InterruptedException if cooperative interruption wins after any required cleanup
3620         * @throws UnsupportedOperationException if the database dialect or runtime driver cannot receive notifications
3621         * @throws DatabaseException if connection acquisition, setup, receive, callback execution, or cleanup fails
3622         * @since 4.6.0
3623         */
3624        public void withNotificationSession(@NonNull String channel,
3625                                                                                                                                                        @NonNull NotificationSessionOperation operation)
3626                        throws InterruptedException {
3627                requireNonNull(channel);
3628                requireNonNull(operation);
3629                withNotificationSession(Set.of(channel), operation);
3630        }
3631
3632        /**
3633         * Sends a transient database notification without specifying a payload.
3634         * <p>
3635         * Sending follows ordinary Pyranid statement and transaction selection. On PostgreSQL, a send inside a Pyranid
3636         * transaction becomes visible only if that transaction commits. Payload representation is database-specific;
3637         * PostgreSQL converts the resulting null payload to the empty string.
3638         *
3639         * @param channel nonblank notification channel
3640         * @throws NullPointerException if {@code channel} is null
3641         * @throws IllegalArgumentException if the channel violates common or backend-specific limits
3642         * @throws UnsupportedOperationException if the database dialect does not support notification sends
3643         * @throws DatabaseException if the send fails
3644         * @since 4.6.0
3645         */
3646        public void sendNotification(@NonNull String channel) {
3647                sendNotification(channel, null);
3648        }
3649
3650        /**
3651         * Sends a transient database notification.
3652         * <p>
3653         * Sending follows ordinary Pyranid statement and transaction selection, including connection ownership,
3654         * parameter binding and redaction, statement logging, timeout configuration, and metrics. Payload nullability
3655         * and null/empty-string handling are database-specific; Pyranid performs no generic normalization.
3656         * <p>
3657         * On PostgreSQL this executes bound {@code pg_notify(?, ?)} SQL. PostgreSQL converts a null payload to the empty
3658         * string. Delivery occurs only after commit and is discarded by rollback; notification delivery itself remains
3659         * non-durable and may be coalesced.
3660         *
3661         * @param channel nonblank notification channel
3662         * @param payload notification payload, possibly null or empty
3663         * @throws NullPointerException if {@code channel} is null
3664         * @throws IllegalArgumentException if the channel or payload violates common or backend-specific limits
3665         * @throws UnsupportedOperationException if the database dialect does not support notification sends
3666         * @throws DatabaseException if the send fails
3667         * @since 4.6.0
3668         */
3669        public void sendNotification(@NonNull String channel,
3670                                                                                                 @Nullable String payload) {
3671                Notification.validateChannel(channel);
3672                DatabaseNotificationSupport notificationSupport = getDatabaseDialect().notificationSupport();
3673
3674                notificationSupport.validateChannel(channel);
3675                notificationSupport.validatePayload(payload);
3676
3677                if (!notificationSupport.isSendSupported())
3678                        throw new UnsupportedOperationException(format(
3679                                        "Database type %s does not support notification sends", getDatabaseType()));
3680
3681                Statement statement = Statement.of(generateId(), notificationSupport.sendStatementSql());
3682                StatementContext<Void> statementContext = StatementContext.with(statement, this)
3683                                .parameters(channel, payload)
3684                                .build();
3685
3686                PreparedStatementCustomizer defaultPreparedStatementCustomizer = hasDefaultPreparedStatementSettings()
3687                                ? (context, preparedStatement) -> applyDefaultPreparedStatementSettings(preparedStatement)
3688                                : null;
3689
3690                performDatabaseOperation(statementContext, Arrays.asList(channel, payload),
3691                                defaultPreparedStatementCustomizer, preparedStatement -> {
3692                        long startTime = nanoTime();
3693                        executeAndDrain(preparedStatement);
3694                        return new DatabaseOperationResult(
3695                                        Duration.ofNanos(nanoTime() - startTime), null, StatementResult.empty());
3696                });
3697        }
3698
3699        /**
3700         * Reports whether the configured database dialect and currently loadable runtime adapter expose the APIs required
3701         * to attempt a notification-listening session.
3702         * <p>
3703         * This method resolves the full database type. If it has not been configured or cached, resolution may acquire a
3704         * metadata connection and may throw {@link DatabaseException}. It does not acquire a listener session, inspect pool
3705         * or proxy mode, prove backend-session affinity, unwrap a physical listener connection, or emit notification-session
3706         * lifecycle metrics.
3707         *
3708         * @return true if notification listening can be attempted with the current dialect and runtime
3709         * @throws DatabaseException if automatic database-type detection fails
3710         * @since 4.6.0
3711         */
3712        @NonNull
3713        public Boolean isNotificationListeningSupported() {
3714                return getDatabaseDialect().notificationSupport().isReceiveRuntimeAvailable();
3715        }
3716
3717        private void executeAndDrain(@NonNull PreparedStatement preparedStatement) throws SQLException {
3718                requireNonNull(preparedStatement);
3719
3720                boolean resultAvailable = preparedStatement.execute();
3721
3722                for (;;) {
3723                        if (resultAvailable) {
3724                                try (ResultSet resultSet = preparedStatement.getResultSet()) {
3725                                        if (resultSet != null) {
3726                                                while (resultSet.next()) {
3727                                                        // Drain every row before advancing to a possible subsequent result.
3728                                                }
3729                                        }
3730                                }
3731                        } else if (preparedStatement.getUpdateCount() == -1) {
3732                                break;
3733                        }
3734
3735                        resultAvailable = preparedStatement.getMoreResults(java.sql.Statement.CLOSE_CURRENT_RESULT);
3736                }
3737        }
3738
3739        private void performNotificationSession(@NonNull Set<@NonNull String> channels,
3740                                                                                                                                                                        @NonNull NotificationSessionOperation operation,
3741                                                                                                                                                                        @NonNull DatabaseType databaseType,
3742                                                                                                                                                                        @NonNull DatabaseNotificationSupport notificationSupport)
3743                        throws InterruptedException {
3744                requireNonNull(channels);
3745                requireNonNull(operation);
3746                requireNonNull(databaseType);
3747                requireNonNull(notificationSupport);
3748
3749                MetricsCollectorDispatcher metricsCollectorDispatcher = getMetricsCollectorDispatcher();
3750                UUID notificationSessionId = metricsCollectorDispatcher.isEnabled() ? UUID.randomUUID() : null;
3751                long lifecycleStartTime = notificationSessionId == null ? 0L : nanoTime();
3752
3753                if (notificationSessionId != null)
3754                        metricsCollectorDispatcher.willOpenNotificationSession(databaseType, notificationSessionId);
3755
3756                Connection connection = null;
3757                NotificationTransport transport = null;
3758                NotificationSession session = null;
3759                Boolean initialAutoCommit = null;
3760                NotificationSetupPhase setupPhase = NotificationSetupPhase.CAPABILITY;
3761                UnsupportedOperationException frameworkUnsupportedFailure = null;
3762                boolean initialSanitationComplete = false;
3763
3764                try {
3765                        if (!notificationSupport.isReceiveRuntimeAvailable()) {
3766                                frameworkUnsupportedFailure = new UnsupportedOperationException(format(
3767                                                "Database type %s does not support notification listening with the current runtime", databaseType));
3768                                throw frameworkUnsupportedFailure;
3769                        }
3770
3771                        setupPhase = NotificationSetupPhase.ACQUIRE_CONNECTION;
3772                        connection = requireNonNull(getDataSource().getConnection(), "DataSource returned a null connection");
3773
3774                        setupPhase = NotificationSetupPhase.CONFIGURE_CONNECTION;
3775                        initialAutoCommit = connection.getAutoCommit();
3776
3777                        if (!initialAutoCommit) {
3778                                // A pooled connection can be returned with an unfinished transaction. Discard inherited work before
3779                                // enabling autocommit; LISTEN registration must not inherit that transaction.
3780                                connection.rollback();
3781                                connection.setAutoCommit(true);
3782                        }
3783
3784                        setupPhase = NotificationSetupPhase.OPEN_TRANSPORT;
3785                        transport = notificationSupport.open(connection);
3786
3787                        setupPhase = NotificationSetupPhase.SANITIZE_UNLISTEN;
3788                        transport.unlistenAll();
3789
3790                        setupPhase = NotificationSetupPhase.SANITIZE_DRAIN;
3791                        requireNonNull(transport.drain(), "Notification transport returned a null batch");
3792
3793                        if (transport.isConnectionUncertain())
3794                                throw new SQLException("Notification transport became uncertain during initial sanitation");
3795
3796                        initialSanitationComplete = true;
3797                        setupPhase = NotificationSetupPhase.REGISTER;
3798                        transport.listen(channels);
3799
3800                        if (transport.isConnectionUncertain())
3801                                throw new SQLException("Notification transport became uncertain during registration");
3802
3803                        session = new NotificationSession(
3804                                        this, requireNonNull(transport), databaseType, notificationSessionId);
3805                } catch (Throwable setupCause) {
3806                        boolean connectionFailure = isNotificationConnectionFailure(connection, setupCause);
3807                        Throwable setupFailure = normalizeNotificationSetupFailure(
3808                                        setupCause, frameworkUnsupportedFailure, setupPhase, connectionFailure, databaseType);
3809
3810                        if (notificationSessionId != null) {
3811                                metricsCollectorDispatcher.didFailToOpenNotificationSession(
3812                                                databaseType, notificationSessionId,
3813                                                Duration.ofNanos(nanoTime() - lifecycleStartTime), setupFailure);
3814                        }
3815
3816                        boolean interruptionObserved = Thread.interrupted();
3817                        // Synthetic uncertainty failures need not carry a connection-class SQLSTATE. Cleanup still fails closed:
3818                        // this independent transport-state check prevents reuse even if generic exception classification does not.
3819                        boolean canAttemptHealthyCleanup = initialSanitationComplete
3820                                        && connection != null
3821                                        && transport != null
3822                                        && initialAutoCommit != null
3823                                        && setupPhase == NotificationSetupPhase.REGISTER
3824                                        && !connectionFailure
3825                                        && !transport.isConnectionUncertain();
3826                        Throwable cleanupFailure = canAttemptHealthyCleanup
3827                                        ? cleanupOpenedNotificationSession(connection, transport, initialAutoCommit, false, databaseType)
3828                                        : cleanupFailedNotificationCandidate(connection, databaseType);
3829                        setupFailure = appendSuppressedIfPresent(setupFailure, cleanupFailure);
3830
3831                        if (Thread.interrupted())
3832                                interruptionObserved = true;
3833
3834                        if (interruptionObserved)
3835                                Thread.currentThread().interrupt();
3836
3837                        throwNotificationFailure(setupFailure);
3838                        return;
3839                }
3840
3841                if (notificationSessionId != null) {
3842                        metricsCollectorDispatcher.didOpenNotificationSession(
3843                                        databaseType, notificationSessionId,
3844                                        Duration.ofNanos(nanoTime() - lifecycleStartTime));
3845                }
3846
3847                session = requireNonNull(session);
3848                Throwable callbackFailure = null;
3849                boolean callbackFailureIsError = false;
3850                InterruptedException concreteInterruptedException = null;
3851                boolean interruptionObserved = false;
3852
3853                try {
3854                        if (Thread.interrupted()) {
3855                                interruptionObserved = true;
3856                        } else {
3857                                operation.perform(session);
3858                        }
3859                } catch (InterruptedException interruptedException) {
3860                        concreteInterruptedException = interruptedException;
3861                        interruptionObserved = true;
3862                        // Retain the exact exception while clearing any simultaneously pending status before cleanup.
3863                        Thread.interrupted();
3864                } catch (DatabaseException databaseException) {
3865                        callbackFailure = databaseException;
3866                } catch (Error error) {
3867                        callbackFailure = error;
3868                        callbackFailureIsError = true;
3869                } catch (Throwable throwable) {
3870                        callbackFailure = new DatabaseException(
3871                                        "Notification session operation failed", throwable, databaseType.dialect());
3872                } finally {
3873                        session.expire();
3874                }
3875
3876                Throwable transportFailure = session.terminalFailure();
3877                Throwable primaryFailure;
3878
3879                if (callbackFailureIsError) {
3880                        primaryFailure = appendSuppressedIfPresent(callbackFailure, transportFailure);
3881                } else if (transportFailure != null) {
3882                        primaryFailure = appendSuppressedIfPresent(transportFailure, callbackFailure);
3883                } else {
3884                        primaryFailure = callbackFailure;
3885                }
3886
3887                // Cleanup begins flag-clear with respect to status already pending at this boundary.
3888                if (Thread.interrupted())
3889                        interruptionObserved = true;
3890
3891                Throwable cleanupFailure = cleanupOpenedNotificationSession(
3892                                requireNonNull(connection), requireNonNull(transport), requireNonNull(initialAutoCommit),
3893                                session.isConnectionUncertain(), databaseType);
3894                primaryFailure = appendSuppressedIfPresent(primaryFailure, cleanupFailure);
3895
3896                if (Thread.interrupted())
3897                        interruptionObserved = true;
3898
3899                MetricsCollector.NotificationSessionOutcome outcome;
3900
3901                if (primaryFailure != null)
3902                        outcome = MetricsCollector.NotificationSessionOutcome.FAILED;
3903                else if (interruptionObserved)
3904                        outcome = MetricsCollector.NotificationSessionOutcome.INTERRUPTED;
3905                else
3906                        outcome = MetricsCollector.NotificationSessionOutcome.CALLBACK_RETURNED;
3907
3908                if (notificationSessionId != null) {
3909                        metricsCollectorDispatcher.didCloseNotificationSession(
3910                                        databaseType, notificationSessionId, outcome,
3911                                        Duration.ofNanos(nanoTime() - lifecycleStartTime),
3912                                        outcome == MetricsCollector.NotificationSessionOutcome.FAILED ? primaryFailure : null);
3913                }
3914
3915                if (primaryFailure != null) {
3916                        if (interruptionObserved) {
3917                                primaryFailure = appendSuppressedIfPresent(primaryFailure, concreteInterruptedException);
3918                                Thread.currentThread().interrupt();
3919                        }
3920
3921                        throwNotificationFailure(primaryFailure);
3922                        return;
3923                }
3924
3925                if (interruptionObserved)
3926                        throw concreteInterruptedException == null ? new InterruptedException() : concreteInterruptedException;
3927        }
3928
3929        @NonNull
3930        private Throwable normalizeNotificationSetupFailure(@NonNull Throwable setupCause,
3931                                                                                                                                                                                                                         @Nullable UnsupportedOperationException frameworkUnsupportedFailure,
3932                                                                                                                                                                                                                         @NonNull NotificationSetupPhase setupPhase,
3933                                                                                                                                                                                                                         boolean connectionFailure,
3934                                                                                                                                                                                                                         @NonNull DatabaseType databaseType) {
3935                requireNonNull(setupCause);
3936                requireNonNull(setupPhase);
3937                requireNonNull(databaseType);
3938
3939                if (setupCause == frameworkUnsupportedFailure)
3940                        return setupCause;
3941
3942                if (setupCause instanceof NotificationReceiveUnsupportedException && !connectionFailure) {
3943                        return new UnsupportedOperationException(
3944                                        "Notification listening is not supported by the current database driver connection", setupCause);
3945                }
3946
3947                if (setupPhase == NotificationSetupPhase.OPEN_TRANSPORT
3948                                && !connectionFailure
3949                                && (setupCause instanceof SQLFeatureNotSupportedException
3950                                || setupCause instanceof UnsupportedOperationException
3951                                || setupCause instanceof AbstractMethodError)) {
3952                        return new UnsupportedOperationException(
3953                                        "Notification listening is not supported by the current database driver connection", setupCause);
3954                }
3955
3956                return normalizeNotificationFailure("Unable to open notification session", setupCause, databaseType);
3957        }
3958
3959        private boolean isNotificationConnectionFailure(@Nullable Connection connection,
3960                                                                                                                                                                                                 @NonNull Throwable throwable) {
3961                requireNonNull(throwable);
3962
3963                if (throwable instanceof SQLException sqlException) {
3964                        String sqlState = sqlException.getSQLState();
3965
3966                        if (sqlState != null && sqlState.startsWith("08"))
3967                                return true;
3968                }
3969
3970                if (connection != null) {
3971                        try {
3972                                return connection.isClosed();
3973                        } catch (Throwable diagnosticFailure) {
3974                                if (diagnosticFailure != throwable)
3975                                        throwable.addSuppressed(diagnosticFailure);
3976
3977                                if (diagnosticFailure instanceof SQLException diagnosticSqlException) {
3978                                        String sqlState = diagnosticSqlException.getSQLState();
3979
3980                                        if (sqlState != null && sqlState.startsWith("08"))
3981                                                return true;
3982                                }
3983
3984                                // If connection health cannot be inspected, never return the candidate to a pool as healthy.
3985                                return true;
3986                        }
3987                }
3988
3989                return false;
3990        }
3991
3992        @Nullable
3993        private Throwable cleanupFailedNotificationCandidate(@Nullable Connection connection,
3994                                                                                                                                                                                                                         @NonNull DatabaseType databaseType) {
3995                requireNonNull(databaseType);
3996
3997                if (connection == null)
3998                        return null;
3999
4000                Throwable cleanupFailure = null;
4001
4002                try {
4003                        connection.abort(Runnable::run);
4004                } catch (Throwable throwable) {
4005                        cleanupFailure = appendSuppressed(cleanupFailure,
4006                                        normalizeNotificationFailure("Unable to abort failed notification-session connection",
4007                                                        throwable, databaseType));
4008                }
4009
4010                try {
4011                        connection.close();
4012                } catch (Throwable throwable) {
4013                        cleanupFailure = appendSuppressed(cleanupFailure,
4014                                        normalizeNotificationFailure("Unable to close failed notification-session connection",
4015                                                        throwable, databaseType));
4016                }
4017
4018                return cleanupFailure;
4019        }
4020
4021        @Nullable
4022        private Throwable cleanupOpenedNotificationSession(@NonNull Connection connection,
4023                                                                                                                                                                                                                 @NonNull NotificationTransport transport,
4024                                                                                                                                                                                                                 @NonNull Boolean initialAutoCommit,
4025                                                                                                                                                                                                                 boolean connectionUncertain,
4026                                                                                                                                                                                                                 @NonNull DatabaseType databaseType) {
4027                requireNonNull(connection);
4028                requireNonNull(transport);
4029                requireNonNull(initialAutoCommit);
4030                requireNonNull(databaseType);
4031
4032                Throwable cleanupFailure = null;
4033                boolean abortRequired = connectionUncertain;
4034
4035                if (!abortRequired) {
4036                        try {
4037                                transport.unlistenAll();
4038                        } catch (Throwable throwable) {
4039                                cleanupFailure = appendSuppressed(cleanupFailure,
4040                                                normalizeNotificationFailure("Unable to unregister notification channels", throwable, databaseType));
4041                                abortRequired = true;
4042                        }
4043                }
4044
4045                if (!abortRequired) {
4046                        try {
4047                                requireNonNull(transport.drain(), "Notification transport returned a null cleanup batch");
4048
4049                                if (transport.isConnectionUncertain())
4050                                        throw new SQLException("Notification transport became uncertain during cleanup");
4051                        } catch (Throwable throwable) {
4052                                cleanupFailure = appendSuppressed(cleanupFailure,
4053                                                normalizeNotificationFailure("Unable to drain notification connection during cleanup",
4054                                                                throwable, databaseType));
4055                                abortRequired = true;
4056                        }
4057                }
4058
4059                if (!abortRequired && !initialAutoCommit) {
4060                        try {
4061                                connection.setAutoCommit(false);
4062                        } catch (Throwable throwable) {
4063                                cleanupFailure = appendSuppressed(cleanupFailure,
4064                                                normalizeNotificationFailure("Unable to restore notification connection autocommit",
4065                                                                throwable, databaseType));
4066                                abortRequired = true;
4067                        }
4068                }
4069
4070                if (!abortRequired) {
4071                        try {
4072                                connection.close();
4073                        } catch (Throwable throwable) {
4074                                cleanupFailure = appendSuppressed(cleanupFailure,
4075                                                normalizeNotificationFailure("Unable to close notification-session connection",
4076                                                                throwable, databaseType));
4077                                // Once close has been invoked, the connection handle might already have been returned to a pool.
4078                                // Do not attempt abort or a second close through a handle whose ownership is now unknown.
4079                        }
4080                }
4081
4082                if (abortRequired) {
4083                        try {
4084                                connection.abort(Runnable::run);
4085                        } catch (Throwable throwable) {
4086                                cleanupFailure = appendSuppressed(cleanupFailure,
4087                                                normalizeNotificationFailure("Unable to abort uncertain notification-session connection",
4088                                                                throwable, databaseType));
4089                        }
4090
4091                        try {
4092                                connection.close();
4093                        } catch (Throwable throwable) {
4094                                cleanupFailure = appendSuppressed(cleanupFailure,
4095                                                normalizeNotificationFailure("Unable to close uncertain notification-session connection",
4096                                                                throwable, databaseType));
4097                        }
4098                }
4099
4100                return cleanupFailure;
4101        }
4102
4103        @NonNull
4104        private Throwable normalizeNotificationFailure(@NonNull String message,
4105                                                                                                                                                                                         @NonNull Throwable throwable,
4106                                                                                                                                                                                         @NonNull DatabaseType databaseType) {
4107                requireNonNull(message);
4108                requireNonNull(throwable);
4109                requireNonNull(databaseType);
4110
4111                if (throwable instanceof DatabaseException || throwable instanceof Error)
4112                        return throwable;
4113
4114                return new DatabaseException(message, throwable, databaseType.dialect());
4115        }
4116
4117        private static void throwNotificationFailure(@NonNull Throwable throwable) {
4118                requireNonNull(throwable);
4119
4120                if (throwable instanceof RuntimeException runtimeException)
4121                        throw runtimeException;
4122
4123                if (throwable instanceof Error error)
4124                        throw error;
4125
4126                throw new DatabaseException("Notification session failed", throwable);
4127        }
4128
4129        @Nullable
4130        private static Throwable appendSuppressedIfPresent(@Nullable Throwable existing,
4131                                                                                                                                                                                                                @Nullable Throwable additional) {
4132                if (additional == null)
4133                        return existing;
4134
4135                return appendSuppressed(existing, additional);
4136        }
4137
4138        private enum NotificationSetupPhase {
4139                CAPABILITY,
4140                ACQUIRE_CONNECTION,
4141                CONFIGURE_CONNECTION,
4142                OPEN_TRANSPORT,
4143                SANITIZE_UNLISTEN,
4144                SANITIZE_DRAIN,
4145                REGISTER
4146        }
4147
4148        private <T> void performDatabaseOperation(@NonNull StatementContext<T> statementContext,
4149                                                                                                                                                                                        @NonNull List<Object> parameters,
4150                                                                                                                                                                                        @NonNull DatabaseOperation databaseOperation) {
4151                requireNonNull(statementContext);
4152                requireNonNull(parameters);
4153                requireNonNull(databaseOperation);
4154
4155                performDatabaseOperation(statementContext, parameters, null, databaseOperation);
4156        }
4157
4158        private <T> void performDatabaseOperation(@NonNull StatementContext<T> statementContext,
4159                                                                                                                                                                                        @NonNull List<Object> parameters,
4160                                                                                                                                                                                        @Nullable PreparedStatementCustomizer preparedStatementCustomizer,
4161                                                                                                                                                                                        @NonNull DatabaseOperation databaseOperation) {
4162                performDatabaseOperation(statementContext, parameters, preparedStatementCustomizer, databaseOperation,
4163                                (connection, context) -> connection.prepareStatement(context.getStatement().getSql()));
4164        }
4165
4166        private <T> void performDatabaseOperation(@NonNull StatementContext<T> statementContext,
4167                                                                                                                                                                                        @NonNull List<Object> parameters,
4168                                                                                                                                                                                        @Nullable PreparedStatementCustomizer preparedStatementCustomizer,
4169                                                                                                                                                                                        @NonNull DatabaseOperation databaseOperation,
4170                                                                                                                                                                                        @NonNull PreparedStatementFactory preparedStatementFactory) {
4171                requireNonNull(statementContext);
4172                requireNonNull(parameters);
4173                requireNonNull(databaseOperation);
4174                requireNonNull(preparedStatementFactory);
4175
4176                performDatabaseOperation(statementContext, (preparedStatement) -> {
4177                        applyPreparedStatementCustomizer(statementContext, preparedStatement, preparedStatementCustomizer);
4178                        if (parameters.size() > 0)
4179                                performPreparedStatementBinding(statementContext, preparedStatement, parameters);
4180                }, databaseOperation, null, preparedStatementFactory);
4181        }
4182
4183        private <T> void performPreparedStatementBinding(@NonNull StatementContext<T> statementContext,
4184                                                                                                                                                                                                                 @NonNull PreparedStatement preparedStatement,
4185                                                                                                                                                                                                                 @NonNull List<Object> parameters) {
4186                requireNonNull(statementContext);
4187                requireNonNull(preparedStatement);
4188                requireNonNull(parameters);
4189
4190                try {
4191                        DefaultPreparedStatementBinder.ParameterSqlTypeResolver parameterSqlTypeResolver =
4192                                        new DefaultPreparedStatementBinder.ParameterSqlTypeResolver(preparedStatement);
4193                        PreparedStatementBinder preparedStatementBinder = preparedStatementBinderFor(statementContext);
4194
4195                        for (int i = 0; i < parameters.size(); ++i) {
4196                                Object parameter = SecureParameterSupport.unwrapSecureAndOptionalParameter(parameters.get(i));
4197                                Integer parameterIndex = i + 1;
4198
4199                                if (parameter != null) {
4200                                        if (preparedStatementBinder instanceof DefaultPreparedStatementBinder defaultPreparedStatementBinder) {
4201                                                defaultPreparedStatementBinder.bindParameter(statementContext, preparedStatement, parameterIndex, parameter,
4202                                                                parameterSqlTypeResolver);
4203                                        } else {
4204                                                preparedStatementBinder.bindParameter(statementContext, preparedStatement, parameterIndex, parameter);
4205                                        }
4206                                } else {
4207                                        Integer sqlType = parameterSqlTypeResolver.determineParameterSqlType(parameterIndex)
4208                                                        .map(DefaultPreparedStatementBinder.ParameterSqlType::getSqlType)
4209                                                        .orElse(Types.NULL);
4210                                        try {
4211                                                preparedStatement.setNull(parameterIndex, sqlType);
4212                                        } catch (SQLException | AbstractMethodError e) {
4213                                                if (sqlType == Types.NULL)
4214                                                        throw e;
4215
4216                                                preparedStatement.setNull(parameterIndex, Types.NULL);
4217                                        }
4218                                }
4219                        }
4220                } catch (Exception e) {
4221                        throw databaseExceptionWithStatementContext(statementContext, e);
4222                }
4223        }
4224
4225        private void applyPreparedStatementCustomizer(@NonNull StatementContext<?> statementContext,
4226                                                                                                                                                                                                        @NonNull PreparedStatement preparedStatement,
4227                                                                                                                                                                                                        @Nullable PreparedStatementCustomizer preparedStatementCustomizer) throws SQLException {
4228                requireNonNull(statementContext);
4229                requireNonNull(preparedStatement);
4230
4231                if (preparedStatementCustomizer == null)
4232                        return;
4233
4234                preparedStatementCustomizer.customize(statementContext, preparedStatement);
4235        }
4236
4237        private boolean hasDefaultPreparedStatementSettings() {
4238                return this.queryTimeout != null || this.fetchSize != null || this.maxRows != null;
4239        }
4240
4241        private void applyDefaultPreparedStatementSettings(@NonNull PreparedStatement preparedStatement) throws SQLException {
4242                requireNonNull(preparedStatement);
4243                applyPreparedStatementSettings(preparedStatement, this.queryTimeout, this.fetchSize, this.maxRows);
4244        }
4245
4246        private static void applyPreparedStatementSettings(@NonNull PreparedStatement preparedStatement,
4247                                                                                                                                                                                                                 @Nullable Duration queryTimeout,
4248                                                                                                                                                                                                                 @Nullable Integer fetchSize,
4249                                                                                                                                                                                                                 @Nullable Integer maxRows) throws SQLException {
4250                requireNonNull(preparedStatement);
4251
4252                if (queryTimeout != null)
4253                        preparedStatement.setQueryTimeout(queryTimeoutSeconds(queryTimeout));
4254
4255                if (fetchSize != null)
4256                        preparedStatement.setFetchSize(fetchSize);
4257
4258                if (maxRows != null)
4259                        preparedStatement.setMaxRows(maxRows);
4260        }
4261
4262        @FunctionalInterface
4263        interface InternalRawConnectionOperation<R> {
4264                @NonNull
4265                Optional<R> perform(@NonNull Connection connection) throws Exception;
4266        }
4267
4268        /**
4269         * Gets the database type for this database.
4270         * <p>
4271         * If {@link Builder#databaseType(DatabaseType)} was not configured and the database type has not already been detected,
4272         * this method may acquire a connection and inspect {@link DatabaseMetaData}.  Configure an explicit database type to avoid
4273         * runtime detection.
4274         *
4275         * @return the database type
4276         * @throws DatabaseException if automatic database type detection fails
4277         * @since 3.0.0
4278         */
4279        @NonNull
4280        public DatabaseType getDatabaseType() {
4281                return getDatabaseType(this.databaseTypeDetectionConnectionHolder.get());
4282        }
4283
4284        @NonNull
4285        DatabaseType peekDatabaseType() {
4286                DatabaseType cachedDatabaseType = this.databaseType.get();
4287                return cachedDatabaseType == null ? DatabaseType.GENERIC : cachedDatabaseType;
4288        }
4289
4290        @NonNull
4291        DatabaseDialect getDatabaseDialect() {
4292                return getDatabaseDialect(this.databaseTypeDetectionConnectionHolder.get());
4293        }
4294
4295        @NonNull
4296        DatabaseDialect getDatabaseDialect(@Nullable Connection connection) {
4297                DatabaseDialect cachedDatabaseDialect = this.databaseDialect.get();
4298
4299                if (cachedDatabaseDialect != null)
4300                        return cachedDatabaseDialect;
4301
4302                DatabaseDialect detectedDatabaseDialect = getDatabaseType(connection).dialect();
4303
4304                if (this.databaseDialect.compareAndSet(null, detectedDatabaseDialect))
4305                        return detectedDatabaseDialect;
4306
4307                return requireNonNull(this.databaseDialect.get());
4308        }
4309
4310        private void warmDatabaseTypeCacheForMetricsIfNeeded(@NonNull StatementContext<?> statementContext) {
4311                requireNonNull(statementContext);
4312
4313                if (!getMetricsCollectorDispatcher().isEnabled())
4314                        return;
4315
4316                // Trigger lazy database-type detection while the statement's JDBC connection is active so later
4317                // transaction-scope metrics can report an accurate db.system.name without opening a second metadata connection.
4318                try {
4319                        statementContext.getDatabaseType();
4320                } catch (Throwable t) {
4321                        this.logger.log(Level.FINE, "Unable to warm database type cache for metrics", t);
4322                }
4323        }
4324
4325        private void dispatchWithDatabaseTypeDetectionConnection(@NonNull Connection connection,
4326                                                                                                                                                                                                                                         @NonNull Runnable operation) {
4327                requireNonNull(connection);
4328                requireNonNull(operation);
4329
4330                Connection previousDatabaseTypeDetectionConnection = this.databaseTypeDetectionConnectionHolder.get();
4331                this.databaseTypeDetectionConnectionHolder.set(connection);
4332
4333                try {
4334                        operation.run();
4335                } finally {
4336                        if (previousDatabaseTypeDetectionConnection == null)
4337                                this.databaseTypeDetectionConnectionHolder.remove();
4338                        else
4339                                this.databaseTypeDetectionConnectionHolder.set(previousDatabaseTypeDetectionConnection);
4340                }
4341        }
4342
4343        @NonNull
4344        private DatabaseType getDatabaseType(@Nullable Connection connection) {
4345                DatabaseType cachedDatabaseType = this.databaseType.get();
4346
4347                if (cachedDatabaseType != null)
4348                        return cachedDatabaseType;
4349
4350                DatabaseType detectedDatabaseType;
4351
4352                try {
4353                        detectedDatabaseType = connection == null
4354                                        ? DatabaseType.fromDataSource(getDataSource())
4355                                        : DatabaseType.fromConnection(connection);
4356                } catch (DatabaseException e) {
4357                        throw new DatabaseException(format(
4358                                        "Unable to determine database type automatically. Configure %s.%s(%s) explicitly to avoid runtime detection.",
4359                                        Builder.class.getSimpleName(), "databaseType", DatabaseType.class.getSimpleName()), e);
4360                }
4361
4362                if (this.databaseType.compareAndSet(null, detectedDatabaseType))
4363                        return detectedDatabaseType;
4364
4365                return this.databaseType.get();
4366        }
4367
4368        /**
4369         * @since 3.0.0
4370         */
4371        @NonNull
4372        public ZoneId getTimeZone() {
4373                return this.timeZone;
4374        }
4375
4376        /**
4377         * How should Pyranid bind {@link java.time.Instant} and {@link java.time.OffsetDateTime} parameters when JDBC
4378         * parameter metadata cannot identify whether the target is {@code TIMESTAMP} or {@code TIMESTAMP WITH TIME ZONE}?
4379         *
4380         * @return behavior to use when timestamp target metadata is unavailable or non-identifying
4381         * @since 4.2.0
4382         */
4383        @NonNull
4384        public AmbiguousTimestampBindingStrategy getAmbiguousTimestampBindingStrategy() {
4385                return this.ambiguousTimestampBindingStrategy;
4386        }
4387
4388        /**
4389         * Gets the configured redactor used for non-secure parameters in diagnostics.
4390         *
4391         * @return the configured parameter redactor
4392         * @since 4.4.0
4393         */
4394        @NonNull
4395        public ParameterRedactor getParameterRedactor() {
4396                return this.parameterRedactor;
4397        }
4398
4399        /**
4400         * Useful for single-shot "utility" calls that operate outside of normal query operations, e.g. pulling DB metadata.
4401         * <p>
4402         * Example: {@link #readDatabaseMetaData(DatabaseMetaDataReader)}.
4403         */
4404        @NonNull
4405        <R> Optional<R> performRawConnectionOperation(@NonNull InternalRawConnectionOperation<R> rawConnectionOperation,
4406                                                                                                                                                                                                                                        @NonNull Boolean shouldParticipateInExistingTransactionIfPossible) {
4407                requireNonNull(rawConnectionOperation);
4408                requireNonNull(shouldParticipateInExistingTransactionIfPossible);
4409
4410                if (shouldParticipateInExistingTransactionIfPossible) {
4411                        Optional<Transaction> transaction = currentTransactionForDatabaseOperation();
4412                        ReentrantLock connectionLock = transaction.isPresent() ? transaction.get().getConnectionLock() : null;
4413                        // Try to participate in txn if it's available
4414                        Connection connection = null;
4415                        Throwable thrown = null;
4416                        boolean connectionLockAcquired = false;
4417
4418                        try {
4419                                if (connectionLock != null) {
4420                                        lockInterruptibly(connectionLock, "use the transaction connection for a raw connection operation");
4421                                        connectionLockAcquired = true;
4422                                }
4423
4424                                if (transaction.isPresent()) {
4425                                        connection = transaction.get().getConnection();
4426                                } else {
4427                                        try {
4428                                                connection = getDataSource().getConnection();
4429                                        } catch (SQLException e) {
4430                                                throw new DatabaseException("Unable to acquire database connection", e);
4431                                        }
4432                                }
4433
4434                                return rawConnectionOperation.perform(connection);
4435                        } catch (DatabaseException e) {
4436                                thrown = e;
4437                                throw e;
4438                        } catch (Error e) {
4439                                thrown = e;
4440                                throw e;
4441                        } catch (Exception e) {
4442                                DatabaseException wrapped = databaseExceptionWithRawConnectionContext(connection, e);
4443                                thrown = wrapped;
4444                                throw wrapped;
4445                        } finally {
4446                                Throwable cleanupFailure = null;
4447
4448                                try {
4449                                        // If this was a single-shot operation (not in a transaction), close the connection
4450                                        if (connection != null && !transaction.isPresent()) {
4451                                                try {
4452                                                        closeConnection(connection);
4453                                                } catch (Throwable cleanupException) {
4454                                                        cleanupFailure = cleanupException;
4455                                                }
4456                                        }
4457                                } finally {
4458                                        if (connectionLockAcquired)
4459                                                connectionLock.unlock();
4460
4461                                        if (cleanupFailure != null) {
4462                                                if (thrown != null) {
4463                                                        thrown.addSuppressed(cleanupFailure);
4464                                                } else if (cleanupFailure instanceof RuntimeException) {
4465                                                        throw (RuntimeException) cleanupFailure;
4466                                                } else if (cleanupFailure instanceof Error) {
4467                                                        throw (Error) cleanupFailure;
4468                                                } else {
4469                                                        throw new RuntimeException(cleanupFailure);
4470                                                }
4471                                        }
4472                                }
4473                        }
4474                } else {
4475                        boolean acquiredConnection = false;
4476                        Connection connection = null;
4477                        Throwable thrown = null;
4478
4479                        // Always get a fresh connection no matter what and close it afterwards
4480                        try {
4481                                connection = getDataSource().getConnection();
4482                                acquiredConnection = true;
4483                                return rawConnectionOperation.perform(connection);
4484                        } catch (DatabaseException e) {
4485                                thrown = e;
4486                                throw e;
4487                        } catch (Error e) {
4488                                thrown = e;
4489                                throw e;
4490                        } catch (Exception e) {
4491                                DatabaseException wrapped = acquiredConnection
4492                                                ? databaseExceptionWithRawConnectionContext(connection, e)
4493                                                : new DatabaseException("Unable to acquire database connection", e);
4494                                thrown = wrapped;
4495                                throw wrapped;
4496                        } finally {
4497                                if (connection != null) {
4498                                        try {
4499                                                closeConnection(connection);
4500                                        } catch (Throwable cleanupException) {
4501                                                if (thrown != null) {
4502                                                        thrown.addSuppressed(cleanupException);
4503                                                } else if (cleanupException instanceof RuntimeException) {
4504                                                        throw (RuntimeException) cleanupException;
4505                                                } else if (cleanupException instanceof Error) {
4506                                                        throw (Error) cleanupException;
4507                                                } else {
4508                                                        throw new RuntimeException(cleanupException);
4509                                                }
4510                                        }
4511                                }
4512                        }
4513                }
4514        }
4515
4516        private <T> void performDatabaseOperation(@NonNull StatementContext<T> statementContext,
4517                                                                                                                                                                                        @NonNull PreparedStatementBindingOperation preparedStatementBindingOperation,
4518                                                                                                                                                                                        @NonNull DatabaseOperation databaseOperation) {
4519                performDatabaseOperation(statementContext, preparedStatementBindingOperation, databaseOperation, null);
4520        }
4521
4522        private <T> void performDatabaseOperation(@NonNull StatementContext<T> statementContext,
4523                                                                                                                                                                                        @NonNull PreparedStatementBindingOperation preparedStatementBindingOperation,
4524                                                                                                                                                                                        @NonNull DatabaseOperation databaseOperation,
4525                                                                                                                                                                                        @Nullable Integer batchSize) {
4526                performDatabaseOperation(statementContext, preparedStatementBindingOperation, databaseOperation, batchSize,
4527                                (connection, context) -> connection.prepareStatement(context.getStatement().getSql()));
4528        }
4529
4530        private <T> void performDatabaseOperation(@NonNull StatementContext<T> statementContext,
4531                                                                                                                                                                                        @NonNull PreparedStatementBindingOperation preparedStatementBindingOperation,
4532                                                                                                                                                                                        @NonNull DatabaseOperation databaseOperation,
4533                                                                                                                                                                                        @Nullable Integer batchSize,
4534                                                                                                                                                                                        @NonNull PreparedStatementFactory preparedStatementFactory) {
4535                requireNonNull(statementContext);
4536                requireNonNull(preparedStatementBindingOperation);
4537                requireNonNull(databaseOperation);
4538                requireNonNull(preparedStatementFactory);
4539
4540                long startTime = nanoTime();
4541                Duration connectionAcquisitionDuration = null;
4542                Duration preparationDuration = null;
4543                Duration executionDuration = null;
4544                Duration resultSetMappingDuration = null;
4545                StatementResult statementResult = StatementResult.empty();
4546                Exception exception = null;
4547                Throwable thrown = null;
4548                Connection connection = null;
4549                long connectionHeldStartTime = 0L;
4550                Optional<Transaction> transaction = currentTransactionForDatabaseOperation();
4551                ReentrantLock connectionLock = transaction.isPresent() ? transaction.get().getConnectionLock() : null;
4552                boolean connectionLockAcquired = false;
4553                Connection previousDatabaseTypeDetectionConnection = null;
4554                boolean databaseTypeDetectionConnectionInstalled = false;
4555
4556                try {
4557                        if (connectionLock != null) {
4558                                lockInterruptibly(connectionLock, "execute a statement on the transaction connection");
4559                                connectionLockAcquired = true;
4560                        }
4561
4562                        boolean alreadyHasConnection = transaction.isPresent() && transaction.get().hasConnection();
4563                        if (transaction.isPresent()) {
4564                                connection = transaction.get().getConnection();
4565                        } else {
4566                                getMetricsCollectorDispatcher().willAcquireStatementConnection(statementContext);
4567                                try {
4568                                        connection = getDataSource().getConnection();
4569                                } catch (SQLException e) {
4570                                        DatabaseException wrapped = new DatabaseException("Unable to acquire database connection", e);
4571                                        connectionAcquisitionDuration = Duration.ofNanos(nanoTime() - startTime);
4572                                        getMetricsCollectorDispatcher().didFailToAcquireStatementConnection(statementContext, peekDatabaseType(),
4573                                                        connectionAcquisitionDuration, wrapped);
4574                                        throw wrapped;
4575                                } catch (RuntimeException e) {
4576                                        connectionAcquisitionDuration = Duration.ofNanos(nanoTime() - startTime);
4577                                        getMetricsCollectorDispatcher().didFailToAcquireStatementConnection(statementContext, peekDatabaseType(),
4578                                                        connectionAcquisitionDuration, e);
4579                                        throw e;
4580                                }
4581                        }
4582
4583                        connectionAcquisitionDuration = alreadyHasConnection ? null : Duration.ofNanos(nanoTime() - startTime);
4584                        previousDatabaseTypeDetectionConnection = this.databaseTypeDetectionConnectionHolder.get();
4585                        this.databaseTypeDetectionConnectionHolder.set(connection);
4586                        databaseTypeDetectionConnectionInstalled = true;
4587
4588                        if (!transaction.isPresent()) {
4589                                connectionHeldStartTime = nanoTime();
4590                                Duration acquiredDuration = connectionAcquisitionDuration;
4591                                MetricsCollectorDispatcher metricsCollectorDispatcher = getMetricsCollectorDispatcher();
4592                                if (metricsCollectorDispatcher.isEnabled())
4593                                        dispatchWithDatabaseTypeDetectionConnection(connection, () ->
4594                                                        metricsCollectorDispatcher.didAcquireStatementConnection(statementContext, acquiredDuration));
4595                        }
4596                        startTime = nanoTime();
4597
4598                        try (PreparedStatement preparedStatement = preparedStatementFactory.prepare(connection, statementContext)) {
4599                                preparedStatementBindingOperation.perform(preparedStatement);
4600                                preparationDuration = Duration.ofNanos(nanoTime() - startTime);
4601
4602                                getMetricsCollectorDispatcher().willExecuteStatement(statementContext);
4603                                DatabaseOperationResult databaseOperationResult = databaseOperation.perform(preparedStatement);
4604                                executionDuration = databaseOperationResult.getExecutionDuration().orElse(null);
4605                                resultSetMappingDuration = databaseOperationResult.getResultSetMappingDuration().orElse(null);
4606                                statementResult = databaseOperationResult.getStatementResult();
4607                                warmDatabaseTypeCacheForMetricsIfNeeded(statementContext);
4608                        }
4609                } catch (DatabaseException e) {
4610                        DatabaseException wrapped = databaseExceptionWithStatementContext(statementContext, e);
4611                        exception = wrapped;
4612                        thrown = wrapped;
4613                        throw wrapped;
4614                } catch (Error e) {
4615                        exception = databaseExceptionWithStatementContext(statementContext, e);
4616                        thrown = e;
4617                        throw e;
4618                } catch (Exception e) {
4619                        DatabaseException wrapped = databaseExceptionWithStatementContext(statementContext, e);
4620                        // Store the wrapped (scrubbed) form in the StatementLog, consistent with the DatabaseException
4621                        // and Error paths - the raw driver exception remains available via the wrapped cause
4622                        exception = wrapped;
4623                        thrown = wrapped;
4624                        throw wrapped;
4625                } finally {
4626                        Throwable cleanupFailure = null;
4627
4628                        if (databaseTypeDetectionConnectionInstalled) {
4629                                if (previousDatabaseTypeDetectionConnection == null)
4630                                        this.databaseTypeDetectionConnectionHolder.remove();
4631                                else
4632                                        this.databaseTypeDetectionConnectionHolder.set(previousDatabaseTypeDetectionConnection);
4633                        }
4634
4635                        try {
4636                                cleanupFailure = closeStatementContextResources(statementContext, cleanupFailure);
4637
4638                                // If this was a single-shot operation (not in a transaction), close the connection
4639                                if (connection != null && !transaction.isPresent()) {
4640                                        Duration heldDuration = Duration.ofNanos(nanoTime() - connectionHeldStartTime);
4641                                        try {
4642                                                closeConnection(connection);
4643                                                getMetricsCollectorDispatcher().didReleaseStatementConnection(statementContext, heldDuration);
4644                                        } catch (Throwable cleanupException) {
4645                                                getMetricsCollectorDispatcher().didFailToReleaseStatementConnection(statementContext, heldDuration, cleanupException);
4646                                                if (cleanupFailure == null)
4647                                                        cleanupFailure = cleanupException;
4648                                                else
4649                                                        cleanupFailure.addSuppressed(cleanupException);
4650                                        }
4651                                }
4652                        } finally {
4653                                if (connectionLockAcquired)
4654                                        connectionLock.unlock();
4655
4656                                StatementLog statementLog =
4657                                                StatementLog.withStatementContext(statementContext)
4658                                                                .connectionAcquisitionDuration(connectionAcquisitionDuration)
4659                                                                .preparationDuration(preparationDuration)
4660                                                                .executionDuration(executionDuration)
4661                                                                .resultSetMappingDuration(resultSetMappingDuration)
4662                                                                .batchSize(batchSize)
4663                                                                .exception(exception)
4664                                                                .build();
4665
4666                                if (thrown == null && exception == null) {
4667                                        getMetricsCollectorDispatcher().didExecuteStatement(statementContext, statementLog, statementResult);
4668                                } else {
4669                                        Throwable statementThrowable = thrown == null ? exception : thrown;
4670                                        getMetricsCollectorDispatcher().didFailToExecuteStatement(statementContext, statementLog, peekDatabaseType(),
4671                                                        requireNonNull(statementThrowable));
4672                                }
4673
4674                                try {
4675                                        getStatementLogger().log(statementLog);
4676                                } catch (Throwable cleanupException) {
4677                                        if (cleanupFailure == null)
4678                                                cleanupFailure = cleanupException;
4679                                        else
4680                                                cleanupFailure.addSuppressed(cleanupException);
4681                                }
4682                        }
4683
4684                        if (cleanupFailure != null) {
4685                                if (thrown != null) {
4686                                        thrown.addSuppressed(cleanupFailure);
4687                                } else if (cleanupFailure instanceof RuntimeException) {
4688                                        throw (RuntimeException) cleanupFailure;
4689                                } else if (cleanupFailure instanceof Error) {
4690                                        throw (Error) cleanupFailure;
4691                                } else {
4692                                        throw new RuntimeException(cleanupFailure);
4693                                }
4694                        }
4695                }
4696        }
4697
4698        @NonNull
4699        DataSource getDataSource() {
4700                return this.dataSource;
4701        }
4702
4703        @NonNull
4704        InstanceProvider getInstanceProvider() {
4705                return this.instanceProvider;
4706        }
4707
4708        @NonNull
4709        private PreparedStatementBinder getPreparedStatementBinder() {
4710                return this.preparedStatementBinder;
4711        }
4712
4713        /**
4714         * Resolves the effective {@link ResultSetMapper} for a statement: the per-query override carried on
4715         * the {@link StatementContext} when present, otherwise the database-wide instance.
4716         */
4717        @NonNull
4718        private ResultSetMapper resultSetMapperFor(@NonNull StatementContext<?> statementContext) {
4719                requireNonNull(statementContext);
4720                ResultSetMapper resultSetMapperOverride = statementContext.getResultSetMapperOverride();
4721                return resultSetMapperOverride != null ? resultSetMapperOverride : getResultSetMapper();
4722        }
4723
4724        /**
4725         * Resolves the effective {@link PreparedStatementBinder} for a statement: the per-query override
4726         * carried on the {@link StatementContext} when present, otherwise the database-wide instance.
4727         */
4728        @NonNull
4729        private PreparedStatementBinder preparedStatementBinderFor(@NonNull StatementContext<?> statementContext) {
4730                requireNonNull(statementContext);
4731                PreparedStatementBinder preparedStatementBinderOverride = statementContext.getPreparedStatementBinderOverride();
4732                return preparedStatementBinderOverride != null ? preparedStatementBinderOverride : getPreparedStatementBinder();
4733        }
4734
4735        @NonNull
4736        ResultSetMapper getResultSetMapper() {
4737                return this.resultSetMapper;
4738        }
4739
4740        @NonNull
4741        private StatementLogger getStatementLogger() {
4742                return this.statementLogger;
4743        }
4744
4745        @NonNull
4746        public MetricsCollector getMetricsCollector() {
4747                return getMetricsCollectorDispatcher().getMetricsCollector();
4748        }
4749
4750        @NonNull
4751        MetricsCollectorDispatcher getMetricsCollectorDispatcher() {
4752                return this.metricsCollectorDispatcher;
4753        }
4754
4755        @NonNull
4756        private DatabaseOperationSupportStatus getExecuteLargeBatchSupported() {
4757                return this.executeLargeBatchSupported;
4758        }
4759
4760        private void setExecuteLargeBatchSupported(@NonNull DatabaseOperationSupportStatus executeLargeBatchSupported) {
4761                requireNonNull(executeLargeBatchSupported);
4762                this.executeLargeBatchSupported = executeLargeBatchSupported;
4763        }
4764
4765        @NonNull
4766        private DatabaseOperationSupportStatus getExecuteLargeUpdateSupported() {
4767                return this.executeLargeUpdateSupported;
4768        }
4769
4770        private void setExecuteLargeUpdateSupported(@NonNull DatabaseOperationSupportStatus executeLargeUpdateSupported) {
4771                requireNonNull(executeLargeUpdateSupported);
4772                this.executeLargeUpdateSupported = executeLargeUpdateSupported;
4773        }
4774
4775        @NonNull
4776        Object generateId() {
4777                // "Unique" keys
4778                return this.defaultIdGenerator.incrementAndGet();
4779        }
4780
4781        @FunctionalInterface
4782        private interface DatabaseOperation {
4783                @NonNull
4784                DatabaseOperationResult perform(@NonNull PreparedStatement preparedStatement) throws Exception;
4785        }
4786
4787        @FunctionalInterface
4788        private interface PreparedStatementFactory {
4789                @NonNull
4790                PreparedStatement prepare(@NonNull Connection connection,
4791                                                                                                                        @NonNull StatementContext<?> statementContext) throws SQLException;
4792        }
4793
4794        @FunctionalInterface
4795        private interface PreparedStatementBindingOperation {
4796                void perform(@NonNull PreparedStatement preparedStatement) throws Exception;
4797        }
4798
4799        @NotThreadSafe
4800        private static final class StreamingResultSet<T> implements java.util.Iterator<T>, AutoCloseable {
4801                private final Database database;
4802                private final StatementContext<T> statementContext;
4803                private final List<Object> parameters;
4804                @Nullable
4805                private final PreparedStatementCustomizer preparedStatementCustomizer;
4806                @NonNull
4807                private final Optional<Transaction> transaction;
4808                @Nullable
4809                private final ReentrantLock connectionLock;
4810                @Nullable
4811                private Connection connection;
4812                @Nullable
4813                private PreparedStatement preparedStatement;
4814                @Nullable
4815                private ResultSet resultSet;
4816                private boolean closed;
4817                private boolean hasNextEvaluated;
4818                private boolean hasNext;
4819                @Nullable
4820                private Duration connectionAcquisitionDuration;
4821                @Nullable
4822                private Duration preparationDuration;
4823                @Nullable
4824                private Duration executionDuration;
4825                private long resultSetMappingNanos;
4826                @Nullable
4827                private Exception exception;
4828                @Nullable
4829                private Throwable thrown;
4830                private long rowsConsumed;
4831                private long openStartTime;
4832                private boolean exhausted;
4833                private boolean openFailed;
4834                private boolean terminalMetricsEmitted;
4835                @Nullable
4836                private Throwable callbackThrowable;
4837                @Nullable
4838                private Throwable iterationThrowable;
4839                @Nullable
4840                private Throwable cleanupFailure;
4841                private long connectionHeldStartTime;
4842                @NonNull
4843                private DatabaseDialect databaseStreamDialect = GenericDialect.INSTANCE;
4844                @NonNull
4845                private DatabaseStreamState databaseStreamState = DatabaseStreamState.none();
4846                private boolean connectionLockAcquired;
4847                private final boolean queryFetchSizeConfigured;
4848
4849                private StreamingResultSet(@NonNull Database database,
4850                                                                                                                         @NonNull StatementContext<T> statementContext,
4851                                                                                                                         @NonNull List<Object> parameters,
4852                                                                                                                         @Nullable PreparedStatementCustomizer preparedStatementCustomizer,
4853                                                                                                                         boolean queryFetchSizeConfigured) {
4854                        this.database = requireNonNull(database);
4855                        this.statementContext = requireNonNull(statementContext);
4856                        this.parameters = requireNonNull(parameters);
4857                        this.preparedStatementCustomizer = preparedStatementCustomizer;
4858                        this.transaction = database.currentTransactionForDatabaseOperation();
4859                        this.connectionLock = this.transaction.isPresent() ? this.transaction.get().getConnectionLock() : null;
4860                        this.queryFetchSizeConfigured = queryFetchSizeConfigured;
4861
4862                        open();
4863                }
4864
4865                private void open() {
4866                        long startTime = nanoTime();
4867                        this.openStartTime = startTime;
4868                        this.database.getMetricsCollectorDispatcher().willOpenStream(this.statementContext);
4869                        Connection previousDatabaseTypeDetectionConnection = null;
4870                        boolean databaseTypeDetectionConnectionInstalled = false;
4871
4872                        try {
4873                                if (this.connectionLock != null) {
4874                                        lockInterruptibly(this.connectionLock, "open a stream on the transaction connection");
4875                                        this.connectionLockAcquired = true;
4876                                }
4877
4878                                boolean alreadyHasConnection = this.transaction.isPresent() && this.transaction.get().hasConnection();
4879                                if (this.transaction.isPresent()) {
4880                                        this.connection = this.transaction.get().getConnection();
4881                                } else {
4882                                        this.database.getMetricsCollectorDispatcher().willAcquireStatementConnection(this.statementContext);
4883                                        try {
4884                                                this.connection = this.database.getDataSource().getConnection();
4885                                        } catch (SQLException e) {
4886                                                DatabaseException wrapped = new DatabaseException("Unable to acquire database connection", e);
4887                                                this.connectionAcquisitionDuration = Duration.ofNanos(nanoTime() - startTime);
4888                                                this.database.getMetricsCollectorDispatcher().didFailToAcquireStatementConnection(this.statementContext,
4889                                                                this.database.peekDatabaseType(), this.connectionAcquisitionDuration, wrapped);
4890                                                throw wrapped;
4891                                        } catch (RuntimeException e) {
4892                                                this.connectionAcquisitionDuration = Duration.ofNanos(nanoTime() - startTime);
4893                                                this.database.getMetricsCollectorDispatcher().didFailToAcquireStatementConnection(this.statementContext,
4894                                                                this.database.peekDatabaseType(), this.connectionAcquisitionDuration, e);
4895                                                throw e;
4896                                        }
4897                                }
4898                                this.connectionAcquisitionDuration = alreadyHasConnection ? null : Duration.ofNanos(nanoTime() - startTime);
4899                                previousDatabaseTypeDetectionConnection = this.database.databaseTypeDetectionConnectionHolder.get();
4900                                this.database.databaseTypeDetectionConnectionHolder.set(this.connection);
4901                                databaseTypeDetectionConnectionInstalled = true;
4902
4903                                if (this.transaction.isEmpty()) {
4904                                        this.connectionHeldStartTime = nanoTime();
4905                                        MetricsCollectorDispatcher metricsCollectorDispatcher = this.database.getMetricsCollectorDispatcher();
4906                                        if (metricsCollectorDispatcher.isEnabled())
4907                                                this.database.dispatchWithDatabaseTypeDetectionConnection(requireNonNull(this.connection), () ->
4908                                                                metricsCollectorDispatcher.didAcquireStatementConnection(this.statementContext, this.connectionAcquisitionDuration));
4909                                }
4910                                startTime = nanoTime();
4911
4912                                this.databaseStreamDialect = databaseDialectForStreamingConnection();
4913                                this.databaseStreamState = this.databaseStreamDialect.configureStreamingConnection(requireNonNull(this.connection), this.transaction.isPresent());
4914
4915                                this.preparedStatement = this.databaseStreamDialect.prepareStreamingStatement(requireNonNull(this.connection), this.statementContext);
4916                                this.database.applyPreparedStatementCustomizer(this.statementContext, this.preparedStatement, this.preparedStatementCustomizer);
4917                                this.databaseStreamDialect.configureStreamingPreparedStatement(this.preparedStatement, this.databaseStreamState,
4918                                                this.transaction.isPresent(), this.queryFetchSizeConfigured);
4919                                if (this.parameters.size() > 0)
4920                                        this.database.performPreparedStatementBinding(this.statementContext, this.preparedStatement, this.parameters);
4921                                this.preparationDuration = Duration.ofNanos(nanoTime() - startTime);
4922
4923                                startTime = nanoTime();
4924                                this.resultSet = this.preparedStatement.executeQuery();
4925                                this.executionDuration = Duration.ofNanos(nanoTime() - startTime);
4926                                this.database.warmDatabaseTypeCacheForMetricsIfNeeded(this.statementContext);
4927                                this.database.getMetricsCollectorDispatcher().didOpenStream(this.statementContext, Duration.ofNanos(nanoTime() - this.openStartTime));
4928                        } catch (DatabaseException e) {
4929                                DatabaseException wrapped = databaseExceptionWithStatementContext(this.statementContext, e);
4930                                this.exception = wrapped;
4931                                this.thrown = wrapped;
4932                                this.openFailed = true;
4933                                this.database.getMetricsCollectorDispatcher().didFailToOpenStream(this.statementContext, this.database.peekDatabaseType(),
4934                                                Duration.ofNanos(nanoTime() - this.openStartTime), wrapped);
4935                                close();
4936                                throw wrapped;
4937                        } catch (Exception e) {
4938                                DatabaseException wrapped = databaseExceptionWithStatementContext(this.statementContext, e);
4939                                this.exception = wrapped;
4940                                this.thrown = wrapped;
4941                                this.openFailed = true;
4942                                this.database.getMetricsCollectorDispatcher().didFailToOpenStream(this.statementContext, this.database.peekDatabaseType(),
4943                                                Duration.ofNanos(nanoTime() - this.openStartTime), wrapped);
4944                                close();
4945                                throw wrapped;
4946                        } catch (Error e) {
4947                                this.exception = databaseExceptionWithStatementContext(this.statementContext, e);
4948                                this.thrown = e;
4949                                this.openFailed = true;
4950                                this.database.getMetricsCollectorDispatcher().didFailToOpenStream(this.statementContext, this.database.peekDatabaseType(),
4951                                                Duration.ofNanos(nanoTime() - this.openStartTime), e);
4952                                close();
4953                                throw e;
4954                        } finally {
4955                                if (databaseTypeDetectionConnectionInstalled) {
4956                                        if (previousDatabaseTypeDetectionConnection == null)
4957                                                this.database.databaseTypeDetectionConnectionHolder.remove();
4958                                        else
4959                                                this.database.databaseTypeDetectionConnectionHolder.set(previousDatabaseTypeDetectionConnection);
4960                                }
4961                        }
4962                }
4963
4964                @NonNull
4965                private DatabaseDialect databaseDialectForStreamingConnection() {
4966                        try {
4967                                return this.database.getDatabaseDialect(requireNonNull(this.connection));
4968                        } catch (DatabaseException e) {
4969                                return this.database.peekDatabaseType().dialect();
4970                        }
4971                }
4972
4973                @Override
4974                public boolean hasNext() {
4975                        if (this.closed)
4976                                return false;
4977
4978                        if (!this.hasNextEvaluated) {
4979                                try {
4980                                        this.hasNext = this.resultSet != null && this.resultSet.next();
4981                                        this.hasNextEvaluated = true;
4982                                        if (!this.hasNext) {
4983                                                this.exhausted = true;
4984                                                close();
4985                                        }
4986                                } catch (SQLException e) {
4987                                        DatabaseException wrapped = databaseExceptionWithStatementContext(this.statementContext, e);
4988                                        this.exception = wrapped;
4989                                        this.thrown = wrapped;
4990                                        this.iterationThrowable = wrapped;
4991                                        close();
4992                                        throw wrapped;
4993                                } catch (RuntimeException e) {
4994                                        this.exception = e;
4995                                        this.thrown = e;
4996                                        this.iterationThrowable = e;
4997                                        close();
4998                                        throw e;
4999                                } catch (Error e) {
5000                                        this.exception = databaseExceptionWithStatementContext(this.statementContext, e);
5001                                        this.thrown = e;
5002                                        this.iterationThrowable = e;
5003                                        close();
5004                                        throw e;
5005                                }
5006                        }
5007
5008                        return this.hasNext;
5009                }
5010
5011                @Override
5012                public T next() {
5013                        if (!hasNext())
5014                                throw new java.util.NoSuchElementException();
5015
5016                        this.hasNextEvaluated = false;
5017                        long startTime = nanoTime();
5018                        Connection previousDatabaseTypeDetectionConnection = this.database.databaseTypeDetectionConnectionHolder.get();
5019                        this.database.databaseTypeDetectionConnectionHolder.set(requireNonNull(this.connection));
5020
5021                        try {
5022                                T value = this.database.resultSetMapperFor(this.statementContext)
5023                                                .map(this.statementContext, requireNonNull(this.resultSet), this.statementContext.getResultSetRowType().get(), this.database.getInstanceProvider())
5024                                                .orElse(null);
5025
5026                                this.resultSetMappingNanos += nanoTime() - startTime;
5027                                this.rowsConsumed++;
5028                                return value;
5029                        } catch (SQLException e) {
5030                                DatabaseException wrapped = databaseExceptionWithStatementContext(this.statementContext,
5031                                                format("Unable to map JDBC %s row to %s", ResultSet.class.getSimpleName(), this.statementContext.getResultSetRowType().get()), e);
5032                                this.exception = wrapped;
5033                                this.thrown = wrapped;
5034                                this.iterationThrowable = wrapped;
5035                                close();
5036                                throw wrapped;
5037                        } catch (DatabaseException e) {
5038                                DatabaseException wrapped = databaseExceptionWithStatementContext(this.statementContext, e);
5039                                this.exception = wrapped;
5040                                this.thrown = wrapped;
5041                                this.iterationThrowable = wrapped;
5042                                close();
5043                                throw wrapped;
5044                        } catch (RuntimeException e) {
5045                                this.exception = e;
5046                                this.thrown = e;
5047                                this.iterationThrowable = e;
5048                                close();
5049                                throw e;
5050                        } catch (Error e) {
5051                                this.exception = databaseExceptionWithStatementContext(this.statementContext,
5052                                                format("Unable to map JDBC %s row to %s", ResultSet.class.getSimpleName(), this.statementContext.getResultSetRowType().get()), e);
5053                                this.thrown = e;
5054                                this.iterationThrowable = e;
5055                                close();
5056                                throw e;
5057                        } finally {
5058                                if (previousDatabaseTypeDetectionConnection == null)
5059                                        this.database.databaseTypeDetectionConnectionHolder.remove();
5060                                else
5061                                        this.database.databaseTypeDetectionConnectionHolder.set(previousDatabaseTypeDetectionConnection);
5062                        }
5063                }
5064
5065                private void callbackFailed(@NonNull Throwable throwable) {
5066                        requireNonNull(throwable);
5067                        this.callbackThrowable = throwable;
5068                }
5069
5070                @Override
5071                public void close() {
5072                        if (this.closed)
5073                                return;
5074
5075                        if (this.connectionLockAcquired && this.connectionLock != null && !this.connectionLock.isHeldByCurrentThread())
5076                                throw new DatabaseException("Transactional streams must be closed by the thread that opened them");
5077
5078                        this.closed = true;
5079                        Throwable cleanupFailure = null;
5080
5081                        try {
5082                                cleanupFailure = closeStatementContextResources(this.statementContext, cleanupFailure);
5083
5084                                if (this.resultSet != null) {
5085                                        try {
5086                                                this.resultSet.close();
5087                                        } catch (Throwable cleanupException) {
5088                                                cleanupFailure = cleanupFailure == null ? cleanupException : addSuppressed(cleanupFailure, cleanupException);
5089                                        }
5090                                }
5091
5092                                if (this.preparedStatement != null) {
5093                                        try {
5094                                                this.preparedStatement.close();
5095                                        } catch (Throwable cleanupException) {
5096                                                cleanupFailure = cleanupFailure == null ? cleanupException : addSuppressed(cleanupFailure, cleanupException);
5097                                        }
5098                                }
5099
5100                                cleanupFailure = completeDialectStreamingConnectionIfNeeded(cleanupFailure);
5101
5102                                if (this.connection != null && this.transaction.isEmpty()) {
5103                                        Duration heldDuration = this.connectionHeldStartTime == 0L
5104                                                        ? Duration.ZERO
5105                                                        : Duration.ofNanos(nanoTime() - this.connectionHeldStartTime);
5106                                        try {
5107                                                this.database.closeConnection(this.connection);
5108                                                this.database.getMetricsCollectorDispatcher().didReleaseStatementConnection(this.statementContext, heldDuration);
5109                                        } catch (Throwable cleanupException) {
5110                                                this.database.getMetricsCollectorDispatcher().didFailToReleaseStatementConnection(this.statementContext, heldDuration, cleanupException);
5111                                                cleanupFailure = cleanupFailure == null ? cleanupException : addSuppressed(cleanupFailure, cleanupException);
5112                                        }
5113                                }
5114                        } finally {
5115                                if (this.connectionLockAcquired) {
5116                                        this.connectionLock.unlock();
5117                                        this.connectionLockAcquired = false;
5118                                }
5119
5120                                Duration mappingDuration = this.resultSetMappingNanos == 0L ? null : Duration.ofNanos(this.resultSetMappingNanos);
5121
5122                                StatementLog statementLog =
5123                                                StatementLog.withStatementContext(this.statementContext)
5124                                                                .connectionAcquisitionDuration(this.connectionAcquisitionDuration)
5125                                                                .preparationDuration(this.preparationDuration)
5126                                                                .executionDuration(this.executionDuration)
5127                                                                .resultSetMappingDuration(mappingDuration)
5128                                                                .exception(this.exception)
5129                                                                .build();
5130
5131                                if (this.thrown == null && this.exception == null) {
5132                                        this.database.getMetricsCollectorDispatcher().didExecuteStatement(this.statementContext, statementLog, StatementResult.empty());
5133                                } else {
5134                                        Throwable statementThrowable = this.thrown == null ? this.exception : this.thrown;
5135                                        this.database.getMetricsCollectorDispatcher().didFailToExecuteStatement(this.statementContext, statementLog,
5136                                                        this.database.peekDatabaseType(), requireNonNull(statementThrowable));
5137                                }
5138
5139                                try {
5140                                        this.database.getStatementLogger().log(statementLog);
5141                                } catch (Throwable cleanupException) {
5142                                        cleanupFailure = cleanupFailure == null ? cleanupException : addSuppressed(cleanupFailure, cleanupException);
5143                                }
5144                        }
5145
5146                        this.cleanupFailure = cleanupFailure;
5147
5148                        if (cleanupFailure != null) {
5149                                if (this.thrown != null) {
5150                                        this.thrown.addSuppressed(cleanupFailure);
5151                                } else if (cleanupFailure instanceof RuntimeException) {
5152                                        throw (RuntimeException) cleanupFailure;
5153                                } else if (cleanupFailure instanceof Error) {
5154                                        throw (Error) cleanupFailure;
5155                                } else {
5156                                        throw new RuntimeException(cleanupFailure);
5157                                }
5158                        }
5159                }
5160
5161                @Nullable
5162                private Throwable completeDialectStreamingConnectionIfNeeded(@Nullable Throwable cleanupFailure) {
5163                        if (this.connection == null)
5164                                return cleanupFailure;
5165
5166                        boolean streamSucceeded = this.thrown == null && this.exception == null && this.callbackThrowable == null && !this.openFailed;
5167                        cleanupFailure = this.databaseStreamDialect.completeStreamingConnection(requireNonNull(this.connection),
5168                                        this.databaseStreamState, streamSucceeded, cleanupFailure);
5169
5170                        this.databaseStreamDialect = GenericDialect.INSTANCE;
5171                        this.databaseStreamState = DatabaseStreamState.none();
5172                        return cleanupFailure;
5173                }
5174
5175                private void emitTerminalMetrics() {
5176                        if (this.terminalMetricsEmitted)
5177                                return;
5178
5179                        this.terminalMetricsEmitted = true;
5180
5181                        if (this.openFailed)
5182                                return;
5183
5184                        MetricsCollector.StreamTerminalOutcome outcome;
5185                        Throwable throwable;
5186
5187                        if (this.iterationThrowable != null) {
5188                                outcome = MetricsCollector.StreamTerminalOutcome.ITERATION_FAILURE;
5189                                throwable = this.iterationThrowable;
5190                        } else if (this.callbackThrowable != null) {
5191                                outcome = MetricsCollector.StreamTerminalOutcome.CALLBACK_FAILURE;
5192                                throwable = this.callbackThrowable;
5193                        } else if (this.exhausted) {
5194                                outcome = MetricsCollector.StreamTerminalOutcome.COMPLETED_NORMALLY;
5195                                throwable = this.cleanupFailure;
5196                        } else {
5197                                outcome = MetricsCollector.StreamTerminalOutcome.EARLY_CLOSE;
5198                                throwable = this.cleanupFailure;
5199                        }
5200
5201                        this.database.getMetricsCollectorDispatcher().didCloseStream(this.statementContext, outcome, this.rowsConsumed,
5202                                        Duration.ofNanos(nanoTime() - this.openStartTime), throwable);
5203                }
5204
5205                @NonNull
5206                private static Throwable addSuppressed(@NonNull Throwable existing,
5207                                                                                                                                                                         @NonNull Throwable additional) {
5208                        existing.addSuppressed(additional);
5209                        return existing;
5210                }
5211        }
5212
5213        /**
5214         * Builder used to construct instances of {@link Database}.
5215         * <p>
5216         * This class is intended for use by a single thread.
5217         *
5218         * @author <a href="https://www.revetkn.com">Mark Allen</a>
5219         * @since 1.0.0
5220         */
5221        @NotThreadSafe
5222        public static class Builder {
5223                @NonNull
5224                private final DataSource dataSource;
5225                @Nullable
5226                private DatabaseType databaseType;
5227                @Nullable
5228                private ZoneId timeZone;
5229                @Nullable
5230                private AmbiguousTimestampBindingStrategy ambiguousTimestampBindingStrategy;
5231                @Nullable
5232                private InstanceProvider instanceProvider;
5233                @Nullable
5234                private PreparedStatementBinder preparedStatementBinder;
5235                @Nullable
5236                private ResultSetMapper resultSetMapper;
5237                @Nullable
5238                private StatementLogger statementLogger;
5239                @Nullable
5240                private ParameterRedactor parameterRedactor;
5241                @Nullable
5242                private MetricsCollector metricsCollector;
5243                @Nullable
5244                private Duration queryTimeout;
5245                @Nullable
5246                private Integer fetchSize;
5247                @Nullable
5248                private Integer maxRows;
5249                @Nullable
5250                private Integer parsedSqlCacheCapacity;
5251
5252                private Builder(@NonNull DataSource dataSource) {
5253                        this.dataSource = requireNonNull(dataSource);
5254                        this.databaseType = null;
5255                        this.metricsCollector = null;
5256                        this.queryTimeout = null;
5257                        this.fetchSize = null;
5258                        this.maxRows = null;
5259                        this.parsedSqlCacheCapacity = null;
5260                }
5261
5262                /**
5263                 * Overrides automatic database type detection.
5264                 * <p>
5265                 * If {@code null}, the database type is detected lazily when database-type-specific behavior is first needed.
5266                 * Supplying a non-null value avoids automatic detection and its metadata lookup entirely.
5267                 *
5268                 * @param databaseType the database type to use (null to enable auto-detection)
5269                 * @return this {@code Builder}, for chaining
5270                 * @since 4.0.0
5271                 */
5272                @NonNull
5273                public Builder databaseType(@Nullable DatabaseType databaseType) {
5274                        this.databaseType = databaseType;
5275                        return this;
5276                }
5277
5278                /**
5279                 * Configures the database time zone Pyranid should use when converting zone-less temporal values.
5280                 * <p>
5281                 * This value is used when mapping {@code TIMESTAMP} values to instant-based Java types, and when binding
5282                 * {@link java.time.Instant} or {@link java.time.OffsetDateTime} parameters to known {@code TIMESTAMP}
5283                 * targets. It also applies to ambiguous timestamp bindings if
5284                 * {@link #ambiguousTimestampBindingStrategy(AmbiguousTimestampBindingStrategy)} is configured with
5285                 * {@link AmbiguousTimestampBindingStrategy#TIMESTAMP_WITHOUT_TIME_ZONE}.
5286                 * <p>
5287                 * If {@code null}, Pyranid uses {@link ZoneId#systemDefault()}.
5288                 *
5289                 * @param timeZone database time zone to use, or {@code null} for the JVM default zone
5290                 * @return this {@code Builder}, for chaining
5291                 * @since 3.0.0
5292                 */
5293                @NonNull
5294                public Builder timeZone(@Nullable ZoneId timeZone) {
5295                        this.timeZone = timeZone;
5296                        return this;
5297                }
5298
5299                /**
5300                 * Configures how Pyranid binds {@link java.time.Instant} and {@link java.time.OffsetDateTime} parameters
5301                 * when JDBC parameter metadata cannot identify whether the target is {@code TIMESTAMP} or
5302                 * {@code TIMESTAMP WITH TIME ZONE}.
5303                 * <p>
5304                 * The default, {@link AmbiguousTimestampBindingStrategy#TIMESTAMP_WITH_TIME_ZONE}, is appropriate for
5305                 * timestamp-with-time-zone targets. Use
5306                 * {@link AmbiguousTimestampBindingStrategy#TIMESTAMP_WITHOUT_TIME_ZONE} for drivers or proxies that
5307                 * cannot provide identifying parameter metadata when your target columns are zone-less {@code TIMESTAMP}
5308                 * values that should be interpreted in {@link #timeZone(ZoneId)}.
5309                 *
5310                 * @param ambiguousTimestampBindingStrategy strategy to use, or {@code null} for the default
5311                 * @return this {@code Builder}, for chaining
5312                 * @since 4.2.0
5313                 */
5314                @NonNull
5315                public Builder ambiguousTimestampBindingStrategy(@Nullable AmbiguousTimestampBindingStrategy ambiguousTimestampBindingStrategy) {
5316                        this.ambiguousTimestampBindingStrategy = ambiguousTimestampBindingStrategy;
5317                        return this;
5318                }
5319
5320                @NonNull
5321                public Builder instanceProvider(@Nullable InstanceProvider instanceProvider) {
5322                        this.instanceProvider = instanceProvider;
5323                        return this;
5324                }
5325
5326                @NonNull
5327                public Builder preparedStatementBinder(@Nullable PreparedStatementBinder preparedStatementBinder) {
5328                        this.preparedStatementBinder = preparedStatementBinder;
5329                        return this;
5330                }
5331
5332                @NonNull
5333                public Builder resultSetMapper(@Nullable ResultSetMapper resultSetMapper) {
5334                        this.resultSetMapper = resultSetMapper;
5335                        return this;
5336                }
5337
5338                /**
5339                 * Configures the statement logger for the {@link Database} being built.
5340                 * <p>
5341                 * {@link StatementLogger} failures are fail-fast: a logger exception can make a successful statement operation throw,
5342                 * and inside a Pyranid transaction it participates in normal rollback handling. If the database statement itself failed,
5343                 * logger failures are suppressed onto the primary statement failure.
5344                 *
5345                 * @param statementLogger statement logger to use, or {@code null} for a no-op logger
5346                 * @return this {@code Builder}, for chaining
5347                 */
5348                @NonNull
5349                public Builder statementLogger(@Nullable StatementLogger statementLogger) {
5350                        this.statementLogger = statementLogger;
5351                        return this;
5352                }
5353
5354                /**
5355                 * Configures the redactor used for non-secure parameters in diagnostics.
5356                 * <p>
5357                 * {@link SecureParameter} values always render via {@link SecureParameter#getMask()} and are never passed to this
5358                 * redactor. Batch executions render a bounded batch summary instead of invoking the redactor for each batch value.
5359                 * Specify {@code null} or omit this setter to render non-secure, non-batch values verbatim.
5360                 *
5361                 * @param parameterRedactor parameter redactor to use, or {@code null} for the default
5362                 * @return this {@code Builder}, for chaining
5363                 * @since 4.4.0
5364                 */
5365                @NonNull
5366                public Builder parameterRedactor(@Nullable ParameterRedactor parameterRedactor) {
5367                        this.parameterRedactor = parameterRedactor;
5368                        return this;
5369                }
5370
5371                /**
5372                 * Configures the metrics collector for the {@link Database} being built.
5373                 * <p>
5374                 * Like all {@code Database} configuration, this value is fixed at {@link #build()} time. Specify {@code null}
5375                 * or omit this setter to disable metrics collection.
5376                 *
5377                 * @param metricsCollector metrics collector to use, or {@code null} to disable
5378                 * @return this {@code Builder}, for chaining
5379                 * @since 4.2.0
5380                 */
5381                @NonNull
5382                public Builder metricsCollector(@Nullable MetricsCollector metricsCollector) {
5383                        this.metricsCollector = metricsCollector;
5384                        return this;
5385                }
5386
5387                /**
5388                 * Configures a database-wide JDBC query timeout default.
5389                 * <p>
5390                 * This maps to {@link java.sql.Statement#setQueryTimeout(int)}. {@code null} leaves the timeout unset.
5391                 * {@link Duration#ZERO} disables the JDBC timeout. Positive sub-second durations are rounded up to one second
5392                 * because JDBC accepts whole seconds. Per-query {@link Query#queryTimeout(Duration)} settings override this value,
5393                 * and {@link Query#customize(PreparedStatementCustomizer)} can override both settings before parameter binding.
5394                 *
5395                 * @param queryTimeout timeout to apply by default, or {@code null} to leave unset
5396                 * @return this {@code Builder}, for chaining
5397                 * @since 4.2.0
5398                 */
5399                @NonNull
5400                public Builder queryTimeout(@Nullable Duration queryTimeout) {
5401                        this.queryTimeout = validateQueryTimeout(queryTimeout);
5402                        return this;
5403                }
5404
5405                /**
5406                 * Configures a database-wide JDBC fetch size default.
5407                 * <p>
5408                 * This maps to {@link java.sql.Statement#setFetchSize(int)}. {@code null} leaves the fetch size unset. A value
5409                 * of {@code 0} uses the driver's default fetch-size behavior. Per-query {@link Query#fetchSize(Integer)}
5410                 * settings override this value, and {@link Query#customize(PreparedStatementCustomizer)} can override both
5411                 * settings before parameter binding.
5412                 *
5413                 * @param fetchSize fetch size to apply by default, or {@code null} to leave unset
5414                 * @return this {@code Builder}, for chaining
5415                 * @since 4.2.0
5416                 */
5417                @NonNull
5418                public Builder fetchSize(@Nullable Integer fetchSize) {
5419                        this.fetchSize = validateNonNegativeStatementSetting("fetchSize", fetchSize);
5420                        return this;
5421                }
5422
5423                /**
5424                 * Configures a database-wide JDBC maximum row count default.
5425                 * <p>
5426                 * This maps to {@link java.sql.Statement#setMaxRows(int)}. {@code null} leaves the maximum row count unset. A
5427                 * value of {@code 0} disables the JDBC row limit. Per-query {@link Query#maxRows(Integer)} settings override
5428                 * this value, and {@link Query#customize(PreparedStatementCustomizer)} can override both settings before parameter
5429                 * binding.
5430                 *
5431                 * @param maxRows maximum rows to apply by default, or {@code null} to leave unset
5432                 * @return this {@code Builder}, for chaining
5433                 * @since 4.2.0
5434                 */
5435                @NonNull
5436                public Builder maxRows(@Nullable Integer maxRows) {
5437                        this.maxRows = validateNonNegativeStatementSetting("maxRows", maxRows);
5438                        return this;
5439                }
5440
5441                /**
5442                 * Configures the size of the parsed SQL cache.
5443                 * <p>
5444                 * A value of {@code 0} disables caching. A value of {@code null} uses the default size.
5445                 *
5446                 * @param parsedSqlCacheCapacity cache size (0 disables caching, null uses default)
5447                 * @return this {@code Builder}, for chaining
5448                 */
5449                @NonNull
5450                public Builder parsedSqlCacheCapacity(@Nullable Integer parsedSqlCacheCapacity) {
5451                        if (parsedSqlCacheCapacity != null && parsedSqlCacheCapacity < 0)
5452                                throw new IllegalArgumentException("parsedSqlCacheCapacity must be >= 0");
5453
5454                        this.parsedSqlCacheCapacity = parsedSqlCacheCapacity;
5455                        return this;
5456                }
5457
5458                @NonNull
5459                public Database build() {
5460                        return new Database(this);
5461                }
5462        }
5463
5464        @ThreadSafe
5465        static class DatabaseOperationResult {
5466                @Nullable
5467                private final Duration executionDuration;
5468                @Nullable
5469                private final Duration resultSetMappingDuration;
5470                @NonNull
5471                private final StatementResult statementResult;
5472
5473                public DatabaseOperationResult(@Nullable Duration executionDuration,
5474                                                                                                                                         @Nullable Duration resultSetMappingDuration) {
5475                        this(executionDuration, resultSetMappingDuration, StatementResult.empty());
5476                }
5477
5478                public DatabaseOperationResult(@Nullable Duration executionDuration,
5479                                                                                                                                         @Nullable Duration resultSetMappingDuration,
5480                                                                                                                                         @NonNull StatementResult statementResult) {
5481                        this.executionDuration = executionDuration;
5482                        this.resultSetMappingDuration = resultSetMappingDuration;
5483                        this.statementResult = requireNonNull(statementResult);
5484                }
5485
5486                @NonNull
5487                public Optional<Duration> getExecutionDuration() {
5488                        return Optional.ofNullable(this.executionDuration);
5489                }
5490
5491                @NonNull
5492                public Optional<Duration> getResultSetMappingDuration() {
5493                        return Optional.ofNullable(this.resultSetMappingDuration);
5494                }
5495
5496                @NonNull
5497                public StatementResult getStatementResult() {
5498                        return this.statementResult;
5499                }
5500        }
5501
5502        @NotThreadSafe
5503        static class ResultHolder<T> {
5504                T value;
5505        }
5506
5507        enum DatabaseOperationSupportStatus {
5508                UNKNOWN,
5509                YES,
5510                NO
5511        }
5512
5513}