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