001/*
002 * Copyright 2015-2022 Transmogrify LLC, 2022-2025 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 javax.annotation.Nonnull;
020import javax.annotation.Nullable;
021import javax.annotation.concurrent.ThreadSafe;
022import javax.sql.DataSource;
023import java.sql.Connection;
024import java.sql.SQLException;
025import java.sql.Savepoint;
026import java.util.Collections;
027import java.util.List;
028import java.util.Optional;
029import java.util.concurrent.CopyOnWriteArrayList;
030import java.util.concurrent.atomic.AtomicLong;
031import java.util.concurrent.locks.ReentrantLock;
032import java.util.function.Consumer;
033import java.util.logging.Logger;
034
035import static java.lang.String.format;
036import static java.util.Objects.requireNonNull;
037
038/**
039 * Represents a database transaction.
040 * <p>
041 * Note that commit and rollback operations are controlled internally by {@link Database}.
042 *
043 * @author <a href="https://www.revetkn.com">Mark Allen</a>
044 * @since 1.0.0
045 */
046@ThreadSafe
047public final class Transaction {
048        @Nonnull
049        private static final AtomicLong ID_GENERATOR;
050
051        static {
052                ID_GENERATOR = new AtomicLong(0);
053        }
054
055        @Nonnull
056        private final Long id;
057        @Nonnull
058        private final DataSource dataSource;
059        @Nonnull
060        private final TransactionIsolation transactionIsolation;
061        @Nonnull
062        private final List<Consumer<TransactionResult>> postTransactionOperations;
063        @Nonnull
064        private final ReentrantLock connectionLock;
065        @Nonnull
066        private final Logger logger;
067
068        @Nullable
069        private Connection connection;
070        @Nonnull
071        private Boolean rollbackOnly;
072        @Nullable
073        private Boolean initialAutoCommit;
074        @Nullable
075        private Integer initialTransactionIsolationJdbcLevel;
076        @Nonnull
077        private Boolean transactionIsolationWasChanged;
078
079        Transaction(@Nonnull DataSource dataSource,
080                                                        @Nonnull TransactionIsolation transactionIsolation) {
081                requireNonNull(dataSource);
082                requireNonNull(transactionIsolation);
083
084                this.id = generateId();
085                this.dataSource = dataSource;
086                this.transactionIsolation = transactionIsolation;
087                this.connection = null;
088                this.rollbackOnly = false;
089                this.initialAutoCommit = null;
090                this.transactionIsolationWasChanged = false;
091                this.postTransactionOperations = new CopyOnWriteArrayList();
092                this.connectionLock = new ReentrantLock();
093                this.logger = Logger.getLogger(Transaction.class.getName());
094        }
095
096        @Override
097        @Nonnull
098        public String toString() {
099                return format("%s{id=%s, transactionIsolation=%s, hasConnection=%s, isRollbackOnly=%s}",
100                                getClass().getSimpleName(), id(), getTransactionIsolation(), hasConnection(), isRollbackOnly());
101        }
102
103        /**
104         * Creates a transaction savepoint that can be rolled back to via {@link #rollback(Savepoint)}.
105         *
106         * @return a transaction savepoint
107         */
108        @Nonnull
109        public Savepoint createSavepoint() {
110                try {
111                        return getConnection().setSavepoint();
112                } catch (SQLException e) {
113                        throw new DatabaseException("Unable to create savepoint", e);
114                }
115        }
116
117        /**
118         * Rolls back to the provided transaction savepoint.
119         *
120         * @param savepoint the savepoint to roll back to
121         */
122        public void rollback(@Nonnull Savepoint savepoint) {
123                requireNonNull(savepoint);
124
125                try {
126                        getConnection().rollback(savepoint);
127                } catch (SQLException e) {
128                        throw new DatabaseException("Unable to roll back to savepoint", e);
129                }
130        }
131
132        /**
133         * Should this transaction be rolled back upon completion?
134         * <p>
135         * Default value is {@code false}.
136         *
137         * @return {@code true} if this transaction should be rolled back, {@code false} otherwise
138         */
139        @Nonnull
140        public Boolean isRollbackOnly() {
141                return this.rollbackOnly;
142        }
143
144        /**
145         * Sets whether this transaction should be rolled back upon completion.
146         *
147         * @param rollbackOnly whether to set this transaction to be rollback-only
148         */
149        public void setRollbackOnly(@Nonnull Boolean rollbackOnly) {
150                requireNonNull(rollbackOnly);
151                this.rollbackOnly = rollbackOnly;
152        }
153
154        /**
155         * Adds an operation to the list of operations to be executed when the transaction completes.
156         *
157         * @param postTransactionOperation the post-transaction operation to add
158         */
159        public void addPostTransactionOperation(@Nonnull Consumer<TransactionResult> postTransactionOperation) {
160                requireNonNull(postTransactionOperation);
161                this.postTransactionOperations.add(postTransactionOperation);
162        }
163
164        /**
165         * Removes an operation from the list of operations to be executed when the transaction completes.
166         *
167         * @param postTransactionOperation the post-transaction operation to remove
168         * @return {@code true} if the post-transaction operation was removed, {@code false} otherwise
169         */
170        @Nonnull
171        public Boolean removePostTransactionOperation(@Nonnull Consumer<TransactionResult> postTransactionOperation) {
172                requireNonNull(postTransactionOperation);
173                return this.postTransactionOperations.remove(postTransactionOperation);
174        }
175
176        /**
177         * Gets an unmodifiable list of post-transaction operations.
178         * <p>
179         * To manipulate the list, use {@link #addPostTransactionOperation(Consumer)} and
180         * {@link #removePostTransactionOperation(Consumer)}.
181         *
182         * @return the list of post-transaction operations
183         */
184        @Nonnull
185        public List<Consumer<TransactionResult>> getPostTransactionOperations() {
186                return Collections.unmodifiableList(this.postTransactionOperations);
187        }
188
189        /**
190         * Get the isolation level for this transaction.
191         *
192         * @return the isolation level
193         */
194        @Nonnull
195        public TransactionIsolation getTransactionIsolation() {
196                return this.transactionIsolation;
197        }
198
199        @Nonnull
200        Long id() {
201                return this.id;
202        }
203
204        @Nonnull
205        Boolean hasConnection() {
206                getConnectionLock().lock();
207
208                try {
209                        return this.connection != null;
210                } finally {
211                        getConnectionLock().unlock();
212                }
213        }
214
215        void commit() {
216                getConnectionLock().lock();
217
218                try {
219                        if (!hasConnection()) {
220                                logger.finer("Transaction has no connection, so nothing to commit");
221                                return;
222                        }
223
224                        logger.finer("Committing transaction...");
225
226                        try {
227                                getConnection().commit();
228                                logger.finer("Transaction committed.");
229                        } catch (SQLException e) {
230                                throw new DatabaseException("Unable to commit transaction", e);
231                        }
232                } finally {
233                        getConnectionLock().unlock();
234                }
235        }
236
237        void rollback() {
238                getConnectionLock().lock();
239
240                try {
241                        if (!hasConnection()) {
242                                logger.finer("Transaction has no connection, so nothing to roll back");
243                                return;
244                        }
245
246                        logger.finer("Rolling back transaction...");
247
248                        try {
249                                getConnection().rollback();
250                                logger.finer("Transaction rolled back.");
251                        } catch (SQLException e) {
252                                throw new DatabaseException("Unable to roll back transaction", e);
253                        }
254                } finally {
255                        getConnectionLock().unlock();
256                }
257        }
258
259        /**
260         * The connection associated with this transaction.
261         * <p>
262         * If no connection is associated yet, we ask the {@link DataSource} for one.
263         *
264         * @return The connection associated with this transaction.
265         * @throws DatabaseException if unable to acquire a connection.
266         */
267        @Nonnull
268        Connection getConnection() {
269                getConnectionLock().lock();
270
271                try {
272                        if (hasConnection())
273                                return this.connection;
274
275                        try {
276                                this.connection = getDataSource().getConnection();
277                        } catch (SQLException e) {
278                                throw new DatabaseException("Unable to acquire database connection", e);
279                        }
280
281                        // Keep track of the initial setting for autocommit since it might need to get changed from "true" to "false" for
282                        // the duration of the transaction and then back to "true" post-transaction.
283                        try {
284                                this.initialAutoCommit = this.connection.getAutoCommit();
285                        } catch (SQLException e) {
286                                throw new DatabaseException("Unable to determine database connection autocommit setting", e);
287                        }
288
289                        // Track initial isolation
290                        try {
291                                this.initialTransactionIsolationJdbcLevel = this.connection.getTransactionIsolation();
292                        } catch (SQLException e) {
293                                throw new DatabaseException("Unable to determine database connection transaction isolation", e);
294                        }
295
296                        // Immediately flip autocommit to false if needed...if initially true, it will get set back to true by Database at
297                        // the end of the transaction
298                        if (this.initialAutoCommit)
299                                setAutoCommit(false);
300
301                        // Apply requested isolation if not DEFAULT and different from current
302                        TransactionIsolation desiredTransactionIsolation = getTransactionIsolation();
303
304                        if (desiredTransactionIsolation != TransactionIsolation.DEFAULT) {
305                                // Safe; only DEFAULT has a null value
306                                int desiredJdbcLevel = desiredTransactionIsolation.getJdbcLevel().get();
307                                // Apply only if different from current (or current unknown)
308                                if (this.initialTransactionIsolationJdbcLevel == null || this.initialTransactionIsolationJdbcLevel.intValue() != desiredJdbcLevel) {
309                                        try {
310                                                // In the future, we might check supportsTransactionIsolationLevel via DatabaseMetaData first.
311                                                // Probably want to calculate that at Database init time and cache it off
312                                                this.connection.setTransactionIsolation(desiredJdbcLevel);
313                                                this.transactionIsolationWasChanged = true;
314                                        } catch (SQLException e) {
315                                                throw new DatabaseException(format("Unable to set transaction isolation to %s", desiredTransactionIsolation.name()), e);
316                                        }
317                                }
318                        }
319
320                        return this.connection;
321                } finally {
322                        getConnectionLock().unlock();
323                }
324        }
325
326        void setAutoCommit(@Nonnull Boolean autoCommit) {
327                requireNonNull(autoCommit);
328
329                try {
330                        getConnection().setAutoCommit(autoCommit);
331                } catch (SQLException e) {
332                        throw new DatabaseException(format("Unable to set database connection autocommit value to '%s'", autoCommit), e);
333                }
334        }
335
336        void restoreTransactionIsolationIfNeeded() {
337                getConnectionLock().lock();
338
339                try {
340                        if (this.connection == null)
341                                return;
342
343                        Integer initialTransactionIsolationJdbcLevel = getInitialTransactionIsolationJdbcLevel().orElse(null);
344
345                        if (getTransactionIsolationWasChanged() && initialTransactionIsolationJdbcLevel != null) {
346                                try {
347                                        this.connection.setTransactionIsolation(initialTransactionIsolationJdbcLevel.intValue());
348                                } catch (SQLException e) {
349                                        throw new DatabaseException("Unable to restore original transaction isolation", e);
350                                } finally {
351                                        this.transactionIsolationWasChanged = false;
352                                }
353                        }
354                } finally {
355                        getConnectionLock().unlock();
356                }
357        }
358
359        @Nonnull
360        Long generateId() {
361                return ID_GENERATOR.incrementAndGet();
362        }
363
364        @Nonnull
365        Optional<Boolean> getInitialAutoCommit() {
366                return Optional.ofNullable(this.initialAutoCommit);
367        }
368
369        @Nonnull
370        DataSource getDataSource() {
371                return this.dataSource;
372        }
373
374        @Nonnull
375        protected Optional<Integer> getInitialTransactionIsolationJdbcLevel() {
376                return Optional.ofNullable(this.initialTransactionIsolationJdbcLevel);
377        }
378
379        @Nonnull
380        protected Boolean getTransactionIsolationWasChanged() {
381                return this.transactionIsolationWasChanged;
382        }
383
384        @Nonnull
385        protected ReentrantLock getConnectionLock() {
386                return this.connectionLock;
387        }
388}