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