001/*
002 * Copyright 2015-2022 Transmogrify LLC, 2022-2024 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 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
075        Transaction(@Nonnull DataSource dataSource,
076                                                        @Nonnull TransactionIsolation transactionIsolation) {
077                requireNonNull(dataSource);
078                requireNonNull(transactionIsolation);
079
080                this.id = generateId();
081                this.dataSource = dataSource;
082                this.transactionIsolation = transactionIsolation;
083                this.connection = null;
084                this.rollbackOnly = false;
085                this.initialAutoCommit = null;
086                this.postTransactionOperations = new CopyOnWriteArrayList();
087                this.connectionLock = new ReentrantLock();
088                this.logger = Logger.getLogger(Transaction.class.getName());
089        }
090
091        @Override
092        @Nonnull
093        public String toString() {
094                return format("%s{id=%s, transactionIsolation=%s, hasConnection=%s, isRollbackOnly=%s}",
095                                getClass().getSimpleName(), id(), getTransactionIsolation(), hasConnection(), isRollbackOnly());
096        }
097
098        /**
099         * Creates a transaction savepoint that can be rolled back to via {@link #rollback(Savepoint)}.
100         *
101         * @return a transaction savepoint
102         */
103        @Nonnull
104        public Savepoint createSavepoint() {
105                try {
106                        return getConnection().setSavepoint();
107                } catch (SQLException e) {
108                        throw new DatabaseException("Unable to create savepoint", e);
109                }
110        }
111
112        /**
113         * Rolls back to the provided transaction savepoint.
114         *
115         * @param savepoint the savepoint to roll back to
116         */
117        public void rollback(@Nonnull Savepoint savepoint) {
118                requireNonNull(savepoint);
119
120                try {
121                        getConnection().rollback(savepoint);
122                } catch (SQLException e) {
123                        throw new DatabaseException("Unable to roll back to savepoint", e);
124                }
125        }
126
127        /**
128         * Should this transaction be rolled back upon completion?
129         * <p>
130         * Default value is {@code false}.
131         *
132         * @return {@code true} if this transaction should be rolled back, {@code false} otherwise
133         */
134        @Nonnull
135        public Boolean isRollbackOnly() {
136                return this.rollbackOnly;
137        }
138
139        /**
140         * Sets whether this transaction should be rolled back upon completion.
141         *
142         * @param rollbackOnly whether to set this transaction to be rollback-only
143         */
144        public void setRollbackOnly(@Nonnull Boolean rollbackOnly) {
145                requireNonNull(rollbackOnly);
146                this.rollbackOnly = rollbackOnly;
147        }
148
149        /**
150         * Adds an operation to the list of operations to be executed when the transaction completes.
151         *
152         * @param postTransactionOperation the post-transaction operation to add
153         */
154        public void addPostTransactionOperation(@Nonnull Consumer<TransactionResult> postTransactionOperation) {
155                requireNonNull(postTransactionOperation);
156                this.postTransactionOperations.add(postTransactionOperation);
157        }
158
159        /**
160         * Removes an operation from the list of operations to be executed when the transaction completes.
161         *
162         * @param postTransactionOperation the post-transaction operation to remove
163         * @return {@code true} if the post-transaction operation was removed, {@code false} otherwise
164         */
165        @Nonnull
166        public Boolean removePostTransactionOperation(@Nonnull Consumer<TransactionResult> postTransactionOperation) {
167                requireNonNull(postTransactionOperation);
168                return this.postTransactionOperations.remove(postTransactionOperation);
169        }
170
171        /**
172         * Gets an unmodifiable list of post-transaction operations.
173         * <p>
174         * To manipulate the list, use {@link #addPostTransactionOperation(Consumer)} and
175         * {@link #removePostTransactionOperation(Consumer)}.
176         *
177         * @return the list of post-transaction operations
178         */
179        @Nonnull
180        public List<Consumer<TransactionResult>> getPostTransactionOperations() {
181                return Collections.unmodifiableList(this.postTransactionOperations);
182        }
183
184        /**
185         * Get the isolation level for this transaction.
186         *
187         * @return the isolation level
188         */
189        @Nonnull
190        public TransactionIsolation getTransactionIsolation() {
191                return this.transactionIsolation;
192        }
193
194        @Nonnull
195        Long id() {
196                return this.id;
197        }
198
199        @Nonnull
200        Boolean hasConnection() {
201                getConnectionLock().lock();
202
203                try {
204                        return this.connection != null;
205                } finally {
206                        getConnectionLock().unlock();
207                }
208        }
209
210        void commit() {
211                getConnectionLock().lock();
212
213                try {
214                        if (!hasConnection()) {
215                                logger.finer("Transaction has no connection, so nothing to commit");
216                                return;
217                        }
218
219                        logger.finer("Committing transaction...");
220
221                        try {
222                                getConnection().commit();
223                                logger.finer("Transaction committed.");
224                        } catch (SQLException e) {
225                                throw new DatabaseException("Unable to commit transaction", e);
226                        }
227                } finally {
228                        getConnectionLock().unlock();
229                }
230        }
231
232        void rollback() {
233                getConnectionLock().lock();
234
235                try {
236                        if (!hasConnection()) {
237                                logger.finer("Transaction has no connection, so nothing to roll back");
238                                return;
239                        }
240
241                        logger.finer("Rolling back transaction...");
242
243                        try {
244                                getConnection().rollback();
245                                logger.finer("Transaction rolled back.");
246                        } catch (SQLException e) {
247                                throw new DatabaseException("Unable to roll back transaction", e);
248                        }
249                } finally {
250                        getConnectionLock().unlock();
251                }
252        }
253
254        /**
255         * The connection associated with this transaction.
256         * <p>
257         * If no connection is associated yet, we ask the {@link DataSource} for one.
258         *
259         * @return The connection associated with this transaction.
260         * @throws DatabaseException if unable to acquire a connection.
261         */
262        @Nonnull
263        Connection getConnection() {
264                getConnectionLock().lock();
265
266                try {
267                        if (hasConnection())
268                                return this.connection;
269
270                        try {
271                                this.connection = getDataSource().getConnection();
272                        } catch (SQLException e) {
273                                throw new DatabaseException("Unable to acquire database connection", e);
274                        }
275
276                        // Keep track of the initial setting for autocommit since it might need to get changed from "true" to "false" for
277                        // the duration of the transaction and then back to "true" post-transaction.
278                        try {
279                                this.initialAutoCommit = this.connection.getAutoCommit();
280                        } catch (SQLException e) {
281                                throw new DatabaseException("Unable to determine database connection autocommit setting", e);
282                        }
283
284                        // Immediately flip autocommit to false if needed...if initially true, it will get set back to true by Database at
285                        // the end of the transaction
286                        if (this.initialAutoCommit)
287                                setAutoCommit(false);
288
289                        return this.connection;
290                } finally {
291                        getConnectionLock().unlock();
292                }
293        }
294
295        void setAutoCommit(@Nonnull Boolean autoCommit) {
296                requireNonNull(autoCommit);
297
298                try {
299                        getConnection().setAutoCommit(autoCommit);
300                } catch (SQLException e) {
301                        throw new DatabaseException(format("Unable to set database connection autocommit value to '%s'", autoCommit), e);
302                }
303        }
304
305        @Nonnull
306        Long generateId() {
307                return ID_GENERATOR.incrementAndGet();
308        }
309
310        @Nonnull
311        Optional<Boolean> getInitialAutoCommit() {
312                return Optional.ofNullable(this.initialAutoCommit);
313        }
314
315        @Nonnull
316        DataSource getDataSource() {
317                return this.dataSource;
318        }
319
320        @Nonnull
321        protected ReentrantLock getConnectionLock() {
322                return this.connectionLock;
323        }
324}