From 8f36380e21fe8208213d2ffdc5f08d0177d0aaba Mon Sep 17 00:00:00 2001 From: andrewlalis Date: Sat, 3 Feb 2024 22:59:29 -0500 Subject: [PATCH] Refactored account history. --- .../perfin/control/AccountViewController.java | 21 ++- .../data/AccountHistoryItemRepository.java | 25 ---- .../andrewlalis/perfin/data/DataSource.java | 4 +- .../perfin/data/HistoryRepository.java | 28 ++++ .../data/impl/JdbcAccountEntryRepository.java | 7 +- .../JdbcAccountHistoryItemRepository.java | 120 ----------------- .../data/impl/JdbcAccountRepository.java | 26 ++-- .../impl/JdbcBalanceRecordRepository.java | 9 +- .../perfin/data/impl/JdbcDataSource.java | 4 +- .../data/impl/JdbcDataSourceFactory.java | 2 +- .../data/impl/JdbcHistoryRepository.java | 125 ++++++++++++++++++ .../data/impl/JdbcTransactionRepository.java | 7 +- .../data/impl/migration/Migrations.java | 1 + .../model/history/AccountHistoryItem.java | 36 ----- .../model/history/AccountHistoryItemType.java | 7 - .../perfin/model/history/HistoryItem.java | 36 +++++ .../perfin/model/history/HistoryTextItem.java | 16 +++ .../AccountHistoryAccountEntryTile.java | 36 ----- .../AccountHistoryBalanceRecordTile.java | 31 ----- .../component/AccountHistoryItemTile.java | 20 ++- .../component/AccountHistoryTextTile.java | 8 +- src/main/java/module-info.java | 1 + .../sql/migration/M002_RefactorHistories.sql | 61 +++++++++ src/main/resources/sql/schema.sql | 51 ++++--- 24 files changed, 349 insertions(+), 333 deletions(-) delete mode 100644 src/main/java/com/andrewlalis/perfin/data/AccountHistoryItemRepository.java create mode 100644 src/main/java/com/andrewlalis/perfin/data/HistoryRepository.java delete mode 100644 src/main/java/com/andrewlalis/perfin/data/impl/JdbcAccountHistoryItemRepository.java create mode 100644 src/main/java/com/andrewlalis/perfin/data/impl/JdbcHistoryRepository.java delete mode 100644 src/main/java/com/andrewlalis/perfin/model/history/AccountHistoryItem.java delete mode 100644 src/main/java/com/andrewlalis/perfin/model/history/AccountHistoryItemType.java create mode 100644 src/main/java/com/andrewlalis/perfin/model/history/HistoryItem.java create mode 100644 src/main/java/com/andrewlalis/perfin/model/history/HistoryTextItem.java delete mode 100644 src/main/java/com/andrewlalis/perfin/view/component/AccountHistoryAccountEntryTile.java delete mode 100644 src/main/java/com/andrewlalis/perfin/view/component/AccountHistoryBalanceRecordTile.java create mode 100644 src/main/resources/sql/migration/M002_RefactorHistories.sql diff --git a/src/main/java/com/andrewlalis/perfin/control/AccountViewController.java b/src/main/java/com/andrewlalis/perfin/control/AccountViewController.java index e89b049..5241538 100644 --- a/src/main/java/com/andrewlalis/perfin/control/AccountViewController.java +++ b/src/main/java/com/andrewlalis/perfin/control/AccountViewController.java @@ -1,12 +1,12 @@ package com.andrewlalis.perfin.control; import com.andrewlalis.javafx_scene_router.RouteSelectionListener; -import com.andrewlalis.perfin.data.AccountHistoryItemRepository; import com.andrewlalis.perfin.data.AccountRepository; +import com.andrewlalis.perfin.data.HistoryRepository; import com.andrewlalis.perfin.data.util.DateUtil; import com.andrewlalis.perfin.model.Account; import com.andrewlalis.perfin.model.Profile; -import com.andrewlalis.perfin.model.history.AccountHistoryItem; +import com.andrewlalis.perfin.model.history.HistoryItem; import com.andrewlalis.perfin.view.component.AccountHistoryItemTile; import javafx.application.Platform; import javafx.beans.binding.BooleanExpression; @@ -131,20 +131,15 @@ public class AccountViewController implements RouteSelectionListener { } @FXML public void loadMoreHistory() { - Profile.getCurrent().dataSource().useRepoAsync(AccountHistoryItemRepository.class, repo -> { - List historyItems = repo.findMostRecentForAccount( - account.id, - loadHistoryFrom, - historyLoadSize - ); - if (historyItems.size() < historyLoadSize) { + Profile.getCurrent().dataSource().useRepoAsync(HistoryRepository.class, repo -> { + long historyId = repo.getOrCreateHistoryForAccount(account.id); + List items = repo.getNItemsBefore(historyId, historyLoadSize, loadHistoryFrom); + if (items.size() < historyLoadSize) { Platform.runLater(() -> loadMoreHistoryButton.setDisable(true)); } else { - loadHistoryFrom = historyItems.getLast().getTimestamp(); + loadHistoryFrom = items.getLast().getTimestamp(); } - List nodes = historyItems.stream() - .map(item -> AccountHistoryItemTile.forItem(item, repo, this)) - .toList(); + List nodes = items.stream().map(AccountHistoryItemTile::forItem).toList(); Platform.runLater(() -> historyItemsVBox.getChildren().addAll(nodes)); }); } diff --git a/src/main/java/com/andrewlalis/perfin/data/AccountHistoryItemRepository.java b/src/main/java/com/andrewlalis/perfin/data/AccountHistoryItemRepository.java deleted file mode 100644 index a669d03..0000000 --- a/src/main/java/com/andrewlalis/perfin/data/AccountHistoryItemRepository.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.andrewlalis.perfin.data; - -import com.andrewlalis.perfin.data.util.DateUtil; -import com.andrewlalis.perfin.model.AccountEntry; -import com.andrewlalis.perfin.model.BalanceRecord; -import com.andrewlalis.perfin.model.history.AccountHistoryItem; - -import java.time.LocalDateTime; -import java.util.List; -import java.util.Optional; - -public interface AccountHistoryItemRepository extends Repository, AutoCloseable { - void recordAccountEntry(LocalDateTime timestamp, long accountId, long entryId); - void recordBalanceRecord(LocalDateTime timestamp, long accountId, long recordId); - void recordText(LocalDateTime timestamp, long accountId, String text); - List findMostRecentForAccount(long accountId, LocalDateTime utcTimestamp, int count); - default Optional getMostRecentForAccount(long accountId) { - var items = findMostRecentForAccount(accountId, DateUtil.nowAsUTC(), 1); - if (items.isEmpty()) return Optional.empty(); - return Optional.of(items.getFirst()); - } - String getTextItem(long itemId); - AccountEntry getAccountEntryItem(long itemId); - BalanceRecord getBalanceRecordItem(long itemId); -} diff --git a/src/main/java/com/andrewlalis/perfin/data/DataSource.java b/src/main/java/com/andrewlalis/perfin/data/DataSource.java index ba57e7a..bd2244f 100644 --- a/src/main/java/com/andrewlalis/perfin/data/DataSource.java +++ b/src/main/java/com/andrewlalis/perfin/data/DataSource.java @@ -33,7 +33,7 @@ public interface DataSource { TransactionVendorRepository getTransactionVendorRepository(); TransactionCategoryRepository getTransactionCategoryRepository(); AttachmentRepository getAttachmentRepository(); - AccountHistoryItemRepository getAccountHistoryItemRepository(); + HistoryRepository getHistoryRepository(); // Repository helper methods: @@ -86,7 +86,7 @@ public interface DataSource { TransactionVendorRepository.class, this::getTransactionVendorRepository, TransactionCategoryRepository.class, this::getTransactionCategoryRepository, AttachmentRepository.class, this::getAttachmentRepository, - AccountHistoryItemRepository.class, this::getAccountHistoryItemRepository + HistoryRepository.class, this::getHistoryRepository ); return (Supplier) repoSuppliers.get(type); } diff --git a/src/main/java/com/andrewlalis/perfin/data/HistoryRepository.java b/src/main/java/com/andrewlalis/perfin/data/HistoryRepository.java new file mode 100644 index 0000000..815f67d --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/data/HistoryRepository.java @@ -0,0 +1,28 @@ +package com.andrewlalis.perfin.data; + +import com.andrewlalis.perfin.data.pagination.Page; +import com.andrewlalis.perfin.data.pagination.PageRequest; +import com.andrewlalis.perfin.data.util.DateUtil; +import com.andrewlalis.perfin.model.history.HistoryItem; +import com.andrewlalis.perfin.model.history.HistoryTextItem; + +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.List; + +public interface HistoryRepository extends Repository, AutoCloseable { + long getOrCreateHistoryForAccount(long accountId); + long getOrCreateHistoryForTransaction(long transactionId); + void deleteHistoryForAccount(long accountId); + void deleteHistoryForTransaction(long transactionId); + + HistoryTextItem addTextItem(long historyId, LocalDateTime utcTimestamp, String description); + default HistoryTextItem addTextItem(long historyId, String description) { + return addTextItem(historyId, DateUtil.nowAsUTC(), description); + } + Page getItems(long historyId, PageRequest pagination); + List getNItemsBefore(long historyId, int n, LocalDateTime timestamp); + default List getNItemsBeforeNow(long historyId, int n) { + return getNItemsBefore(historyId, n, LocalDateTime.now(ZoneOffset.UTC)); + } +} diff --git a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcAccountEntryRepository.java b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcAccountEntryRepository.java index 18fdd6f..0ec481b 100644 --- a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcAccountEntryRepository.java +++ b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcAccountEntryRepository.java @@ -1,7 +1,7 @@ package com.andrewlalis.perfin.data.impl; import com.andrewlalis.perfin.data.AccountEntryRepository; -import com.andrewlalis.perfin.data.AccountHistoryItemRepository; +import com.andrewlalis.perfin.data.HistoryRepository; import com.andrewlalis.perfin.data.util.DbUtil; import com.andrewlalis.perfin.model.AccountEntry; @@ -31,8 +31,9 @@ public record JdbcAccountEntryRepository(Connection conn) implements AccountEntr ) ); // Insert an entry into the account's history. - AccountHistoryItemRepository historyRepo = new JdbcAccountHistoryItemRepository(conn); - historyRepo.recordAccountEntry(timestamp, accountId, entryId); + HistoryRepository historyRepo = new JdbcHistoryRepository(conn); + long historyId = historyRepo.getOrCreateHistoryForAccount(accountId); + historyRepo.addTextItem(historyId, timestamp, "Entry #" + entryId + " added as a " + type.name() + " from Transaction #" + transactionId + "."); return entryId; } diff --git a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcAccountHistoryItemRepository.java b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcAccountHistoryItemRepository.java deleted file mode 100644 index eda1a7c..0000000 --- a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcAccountHistoryItemRepository.java +++ /dev/null @@ -1,120 +0,0 @@ -package com.andrewlalis.perfin.data.impl; - -import com.andrewlalis.perfin.data.AccountHistoryItemRepository; -import com.andrewlalis.perfin.data.util.DbUtil; -import com.andrewlalis.perfin.model.AccountEntry; -import com.andrewlalis.perfin.model.BalanceRecord; -import com.andrewlalis.perfin.model.history.AccountHistoryItem; -import com.andrewlalis.perfin.model.history.AccountHistoryItemType; - -import java.sql.Connection; -import java.sql.ResultSet; -import java.sql.SQLException; -import java.time.LocalDateTime; -import java.util.List; - -public record JdbcAccountHistoryItemRepository(Connection conn) implements AccountHistoryItemRepository { - @Override - public void recordAccountEntry(LocalDateTime timestamp, long accountId, long entryId) { - long itemId = insertHistoryItem(timestamp, accountId, AccountHistoryItemType.ACCOUNT_ENTRY); - DbUtil.insertOne( - conn, - "INSERT INTO account_history_item_account_entry (item_id, entry_id) VALUES (?, ?)", - List.of(itemId, entryId) - ); - } - - @Override - public void recordBalanceRecord(LocalDateTime timestamp, long accountId, long recordId) { - long itemId = insertHistoryItem(timestamp, accountId, AccountHistoryItemType.BALANCE_RECORD); - DbUtil.insertOne( - conn, - "INSERT INTO account_history_item_balance_record (item_id, record_id) VALUES (?, ?)", - List.of(itemId, recordId) - ); - } - - @Override - public void recordText(LocalDateTime timestamp, long accountId, String text) { - long itemId = insertHistoryItem(timestamp, accountId, AccountHistoryItemType.TEXT); - DbUtil.insertOne( - conn, - "INSERT INTO account_history_item_text (item_id, description) VALUES (?, ?)", - List.of(itemId, text) - ); - } - - @Override - public List findMostRecentForAccount(long accountId, LocalDateTime utcTimestamp, int count) { - return DbUtil.findAll( - conn, - "SELECT * FROM account_history_item WHERE account_id = ? AND timestamp < ? ORDER BY timestamp DESC LIMIT " + count, - List.of(accountId, DbUtil.timestampFromUtcLDT(utcTimestamp)), - JdbcAccountHistoryItemRepository::parseHistoryItem - ); - } - - @Override - public String getTextItem(long itemId) { - return DbUtil.findOne( - conn, - "SELECT description FROM account_history_item_text WHERE item_id = ?", - List.of(itemId), - rs -> rs.getString(1) - ).orElse(null); - } - - @Override - public AccountEntry getAccountEntryItem(long itemId) { - return DbUtil.findOne( - conn, - """ - SELECT * - FROM account_entry - LEFT JOIN account_history_item_account_entry h ON h.entry_id = account_entry.id - WHERE h.item_id = ?""", - List.of(itemId), - JdbcAccountEntryRepository::parse - ).orElse(null); - } - - @Override - public BalanceRecord getBalanceRecordItem(long itemId) { - return DbUtil.findOne( - conn, - """ - SELECT * - FROM balance_record - LEFT JOIN account_history_item_balance_record h ON h.record_id = balance_record.id - WHERE h.item_id = ?""", - List.of(itemId), - JdbcBalanceRecordRepository::parse - ).orElse(null); - } - - @Override - public void close() throws Exception { - conn.close(); - } - - public static AccountHistoryItem parseHistoryItem(ResultSet rs) throws SQLException { - return new AccountHistoryItem( - rs.getLong("id"), - DbUtil.utcLDTFromTimestamp(rs.getTimestamp("timestamp")), - rs.getLong("account_id"), - AccountHistoryItemType.valueOf(rs.getString("type")) - ); - } - - private long insertHistoryItem(LocalDateTime timestamp, long accountId, AccountHistoryItemType type) { - return DbUtil.insertOne( - conn, - "INSERT INTO account_history_item (timestamp, account_id, type) VALUES (?, ?, ?)", - List.of( - DbUtil.timestampFromUtcLDT(timestamp), - accountId, - type.name() - ) - ); - } -} diff --git a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcAccountRepository.java b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcAccountRepository.java index 8a36051..73faf1d 100644 --- a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcAccountRepository.java +++ b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcAccountRepository.java @@ -1,12 +1,8 @@ package com.andrewlalis.perfin.data.impl; -import com.andrewlalis.perfin.data.AccountEntryRepository; -import com.andrewlalis.perfin.data.AccountRepository; -import com.andrewlalis.perfin.data.BalanceRecordRepository; -import com.andrewlalis.perfin.data.EntityNotFoundException; +import com.andrewlalis.perfin.data.*; import com.andrewlalis.perfin.data.pagination.Page; import com.andrewlalis.perfin.data.pagination.PageRequest; -import com.andrewlalis.perfin.data.util.DateUtil; import com.andrewlalis.perfin.data.util.DbUtil; import com.andrewlalis.perfin.model.Account; import com.andrewlalis.perfin.model.AccountEntry; @@ -43,8 +39,9 @@ public record JdbcAccountRepository(Connection conn, Path contentDir) implements ) ); // Insert a history item indicating the creation of the account. - var historyRepo = new JdbcAccountHistoryItemRepository(conn); - historyRepo.recordText(DateUtil.nowAsUTC(), accountId, "Account added to your Perfin profile."); + HistoryRepository historyRepo = new JdbcHistoryRepository(conn); + long historyId = historyRepo.getOrCreateHistoryForAccount(accountId); + historyRepo.addTextItem(historyId, "Account added to your Perfin profile."); return accountId; }); } @@ -59,11 +56,12 @@ public record JdbcAccountRepository(Connection conn, Path contentDir) implements return DbUtil.findAll( conn, """ - SELECT DISTINCT ON (account.id) account.*, ahi.timestamp AS _ + SELECT DISTINCT ON (account.id) account.*, hi.timestamp AS _ FROM account - LEFT OUTER JOIN account_history_item ahi ON ahi.account_id = account.id + LEFT OUTER JOIN history_account ha ON ha.account_id = account.id + LEFT OUTER JOIN history_item hi ON hi.history_id = ha.history_id WHERE NOT account.archived - ORDER BY ahi.timestamp DESC, account.created_at DESC""", + ORDER BY hi.timestamp DESC, account.created_at DESC""", JdbcAccountRepository::parseAccount ); } @@ -160,7 +158,9 @@ public record JdbcAccountRepository(Connection conn, Path contentDir) implements public void archive(long accountId) { DbUtil.doTransaction(conn, () -> { DbUtil.updateOne(conn, "UPDATE account SET archived = TRUE WHERE id = ?", List.of(accountId)); - new JdbcAccountHistoryItemRepository(conn).recordText(DateUtil.nowAsUTC(), accountId, "Account has been archived."); + HistoryRepository historyRepo = new JdbcHistoryRepository(conn); + long historyId = historyRepo.getOrCreateHistoryForAccount(accountId); + historyRepo.addTextItem(historyId, "Account has been archived."); }); } @@ -168,7 +168,9 @@ public record JdbcAccountRepository(Connection conn, Path contentDir) implements public void unarchive(long accountId) { DbUtil.doTransaction(conn, () -> { DbUtil.updateOne(conn, "UPDATE account SET archived = FALSE WHERE id = ?", List.of(accountId)); - new JdbcAccountHistoryItemRepository(conn).recordText(DateUtil.nowAsUTC(), accountId, "Account has been unarchived."); + HistoryRepository historyRepo = new JdbcHistoryRepository(conn); + long historyId = historyRepo.getOrCreateHistoryForAccount(accountId); + historyRepo.addTextItem(historyId, "Account has been unarchived."); }); } diff --git a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcBalanceRecordRepository.java b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcBalanceRecordRepository.java index 44f42d2..da34639 100644 --- a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcBalanceRecordRepository.java +++ b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcBalanceRecordRepository.java @@ -1,11 +1,13 @@ package com.andrewlalis.perfin.data.impl; -import com.andrewlalis.perfin.data.AccountHistoryItemRepository; import com.andrewlalis.perfin.data.AttachmentRepository; import com.andrewlalis.perfin.data.BalanceRecordRepository; +import com.andrewlalis.perfin.data.HistoryRepository; +import com.andrewlalis.perfin.data.util.CurrencyUtil; import com.andrewlalis.perfin.data.util.DbUtil; import com.andrewlalis.perfin.model.Attachment; import com.andrewlalis.perfin.model.BalanceRecord; +import com.andrewlalis.perfin.model.MoneyValue; import java.math.BigDecimal; import java.nio.file.Path; @@ -36,8 +38,9 @@ public record JdbcBalanceRecordRepository(Connection conn, Path contentDir) impl } } // Add a history item entry. - AccountHistoryItemRepository historyRepo = new JdbcAccountHistoryItemRepository(conn); - historyRepo.recordBalanceRecord(utcTimestamp, accountId, recordId); + HistoryRepository historyRepo = new JdbcHistoryRepository(conn); + long historyId = historyRepo.getOrCreateHistoryForAccount(accountId); + historyRepo.addTextItem(historyId, utcTimestamp, "Balance Record #" + recordId + " added with a value of " + CurrencyUtil.formatMoneyWithCurrencyPrefix(new MoneyValue(balance, currency))); return recordId; }); } diff --git a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcDataSource.java b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcDataSource.java index 9ad342e..9f7b172 100644 --- a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcDataSource.java +++ b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcDataSource.java @@ -65,7 +65,7 @@ public class JdbcDataSource implements DataSource { } @Override - public AccountHistoryItemRepository getAccountHistoryItemRepository() { - return new JdbcAccountHistoryItemRepository(getConnection()); + public HistoryRepository getHistoryRepository() { + return new JdbcHistoryRepository(getConnection()); } } diff --git a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcDataSourceFactory.java b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcDataSourceFactory.java index da23d12..d5320f4 100644 --- a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcDataSourceFactory.java +++ b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcDataSourceFactory.java @@ -35,7 +35,7 @@ public class JdbcDataSourceFactory implements DataSourceFactory { * the profile has a newer schema version, we'll exit and prompt the user * to update their app. */ - public static final int SCHEMA_VERSION = 2; + public static final int SCHEMA_VERSION = 3; public DataSource getDataSource(String profileName) throws ProfileLoadException { final boolean dbExists = Files.exists(getDatabaseFile(profileName)); diff --git a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcHistoryRepository.java b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcHistoryRepository.java new file mode 100644 index 0000000..5626d38 --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcHistoryRepository.java @@ -0,0 +1,125 @@ +package com.andrewlalis.perfin.data.impl; + +import com.andrewlalis.perfin.data.HistoryRepository; +import com.andrewlalis.perfin.data.pagination.Page; +import com.andrewlalis.perfin.data.pagination.PageRequest; +import com.andrewlalis.perfin.data.util.DateUtil; +import com.andrewlalis.perfin.data.util.DbUtil; +import com.andrewlalis.perfin.model.history.HistoryItem; +import com.andrewlalis.perfin.model.history.HistoryTextItem; + +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.time.LocalDateTime; +import java.util.List; + +public record JdbcHistoryRepository(Connection conn) implements HistoryRepository { + @Override + public long getOrCreateHistoryForAccount(long accountId) { + return getOrCreateHistoryForEntity(accountId, "history_account", "account_id"); + } + + @Override + public long getOrCreateHistoryForTransaction(long transactionId) { + return getOrCreateHistoryForEntity(transactionId, "history_transaction", "transaction_id"); + } + + private long getOrCreateHistoryForEntity(long entityId, String joinTableName, String joinColumn) { + String selectQuery = "SELECT history_id FROM " + joinTableName + " WHERE " + joinColumn + " = ?"; + var optionalHistoryId = DbUtil.findById(conn, selectQuery, entityId, rs -> rs.getLong(1)); + if (optionalHistoryId.isPresent()) return optionalHistoryId.get(); + long historyId = DbUtil.insertOne(conn, "INSERT INTO history () VALUES ()"); + String insertQuery = "INSERT INTO " + joinTableName + " (" + joinColumn + ", history_id) VALUES (?, ?)"; + DbUtil.updateOne(conn, insertQuery, entityId, historyId); + return historyId; + } + + @Override + public void deleteHistoryForAccount(long accountId) { + deleteHistoryForEntity(accountId, "history_account", "account_id"); + } + + @Override + public void deleteHistoryForTransaction(long transactionId) { + deleteHistoryForEntity(transactionId, "history_transaction", "transaction_id"); + } + + private void deleteHistoryForEntity(long entityId, String joinTableName, String joinColumn) { + String selectQuery = "SELECT history_id FROM " + joinTableName + " WHERE " + joinColumn + " = ?"; + var optionalHistoryId = DbUtil.findById(conn, selectQuery, entityId, rs -> rs.getLong(1)); + if (optionalHistoryId.isPresent()) { + long historyId = optionalHistoryId.get(); + DbUtil.updateOne(conn, "DELETE FROM history WHERE id = ?", historyId); + } + } + + @Override + public HistoryTextItem addTextItem(long historyId, LocalDateTime utcTimestamp, String description) { + long itemId = insertHistoryItem(historyId, utcTimestamp, HistoryItem.TYPE_TEXT); + DbUtil.updateOne( + conn, + "INSERT INTO history_item_text (id, description) VALUES (?, ?)", + itemId, + description + ); + return new HistoryTextItem(itemId, historyId, utcTimestamp, description); + } + + private long insertHistoryItem(long historyId, LocalDateTime timestamp, String type) { + return DbUtil.insertOne( + conn, + "INSERT INTO history_item (history_id, timestamp, type) VALUES (?, ?, ?)", + historyId, + DbUtil.timestampFromUtcLDT(timestamp), + type + ); + } + + @Override + public Page getItems(long historyId, PageRequest pagination) { + return DbUtil.findAll( + conn, + "SELECT * FROM history_item WHERE history_id = ?", + pagination, + List.of(historyId), + JdbcHistoryRepository::parseItem + ); + } + + @Override + public List getNItemsBefore(long historyId, int n, LocalDateTime timestamp) { + return DbUtil.findAll( + conn, + """ + SELECT * + FROM history_item + WHERE history_id = ? AND timestamp <= ? + ORDER BY timestamp DESC""", + List.of(historyId, DbUtil.timestampFromUtcLDT(timestamp)), + JdbcHistoryRepository::parseItem + ); + } + + @Override + public void close() throws Exception { + conn.close(); + } + + public static HistoryItem parseItem(ResultSet rs) throws SQLException { + long id = rs.getLong(1); + long historyId = rs.getLong(2); + LocalDateTime timestamp = DbUtil.utcLDTFromTimestamp(rs.getTimestamp(3)); + String type = rs.getString(4); + if (type.equalsIgnoreCase(HistoryItem.TYPE_TEXT)) { + String description = DbUtil.findOne( + rs.getStatement().getConnection(), + "SELECT description FROM history_item_text WHERE id = ?", + List.of(id), + r -> r.getString(1) + ).orElseThrow(); + return new HistoryTextItem(id, historyId, timestamp, description); + } + throw new SQLException("Unknown history item type: " + type); + } +} diff --git a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcTransactionRepository.java b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcTransactionRepository.java index 497ad27..edd8bb1 100644 --- a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcTransactionRepository.java +++ b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcTransactionRepository.java @@ -2,6 +2,7 @@ package com.andrewlalis.perfin.data.impl; import com.andrewlalis.perfin.data.AccountEntryRepository; import com.andrewlalis.perfin.data.AttachmentRepository; +import com.andrewlalis.perfin.data.HistoryRepository; import com.andrewlalis.perfin.data.TransactionRepository; import com.andrewlalis.perfin.data.pagination.Page; import com.andrewlalis.perfin.data.pagination.PageRequest; @@ -386,9 +387,9 @@ public record JdbcTransactionRepository(Connection conn, Path contentDir) implem // Add a text history item to any linked accounts detailing the changes. String updateMessageStr = "Transaction #" + tx.id + " was updated:\n" + String.join("\n", updateMessages); - var historyRepo = new JdbcAccountHistoryItemRepository(conn); - linkedAccounts.ifCredit(acc -> historyRepo.recordText(DateUtil.nowAsUTC(), acc.id, updateMessageStr)); - linkedAccounts.ifDebit(acc -> historyRepo.recordText(DateUtil.nowAsUTC(), acc.id, updateMessageStr)); + HistoryRepository historyRepo = new JdbcHistoryRepository(conn); + long historyId = historyRepo.getOrCreateHistoryForTransaction(id); + historyRepo.addTextItem(historyId, updateMessageStr); }); } diff --git a/src/main/java/com/andrewlalis/perfin/data/impl/migration/Migrations.java b/src/main/java/com/andrewlalis/perfin/data/impl/migration/Migrations.java index 6e80290..3893fae 100644 --- a/src/main/java/com/andrewlalis/perfin/data/impl/migration/Migrations.java +++ b/src/main/java/com/andrewlalis/perfin/data/impl/migration/Migrations.java @@ -17,6 +17,7 @@ public class Migrations { public static Map getMigrations() { final Map migrations = new HashMap<>(); migrations.put(1, new PlainSQLMigration("/sql/migration/M001_AddTransactionProperties.sql")); + migrations.put(2, new PlainSQLMigration("/sql/migration/M002_RefactorHistories.sql")); return migrations; } diff --git a/src/main/java/com/andrewlalis/perfin/model/history/AccountHistoryItem.java b/src/main/java/com/andrewlalis/perfin/model/history/AccountHistoryItem.java deleted file mode 100644 index 565452e..0000000 --- a/src/main/java/com/andrewlalis/perfin/model/history/AccountHistoryItem.java +++ /dev/null @@ -1,36 +0,0 @@ -package com.andrewlalis.perfin.model.history; - -import com.andrewlalis.perfin.model.IdEntity; - -import java.time.LocalDateTime; - -/** - * The base class representing account history items, a read-only record of an - * account's data and changes over time. The type of history item determines - * what exactly it means, and could be something like an account entry, balance - * record, or modifications to the account's properties. - */ -public class AccountHistoryItem extends IdEntity { - private final LocalDateTime timestamp; - private final long accountId; - private final AccountHistoryItemType type; - - public AccountHistoryItem(long id, LocalDateTime timestamp, long accountId, AccountHistoryItemType type) { - super(id); - this.timestamp = timestamp; - this.accountId = accountId; - this.type = type; - } - - public LocalDateTime getTimestamp() { - return timestamp; - } - - public long getAccountId() { - return accountId; - } - - public AccountHistoryItemType getType() { - return type; - } -} diff --git a/src/main/java/com/andrewlalis/perfin/model/history/AccountHistoryItemType.java b/src/main/java/com/andrewlalis/perfin/model/history/AccountHistoryItemType.java deleted file mode 100644 index eeeac1d..0000000 --- a/src/main/java/com/andrewlalis/perfin/model/history/AccountHistoryItemType.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.andrewlalis.perfin.model.history; - -public enum AccountHistoryItemType { - TEXT, - ACCOUNT_ENTRY, - BALANCE_RECORD -} diff --git a/src/main/java/com/andrewlalis/perfin/model/history/HistoryItem.java b/src/main/java/com/andrewlalis/perfin/model/history/HistoryItem.java new file mode 100644 index 0000000..7e0d7b7 --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/model/history/HistoryItem.java @@ -0,0 +1,36 @@ +package com.andrewlalis.perfin.model.history; + +import com.andrewlalis.perfin.model.IdEntity; + +import java.time.LocalDateTime; + +/** + * Represents a single polymorphic history item. The item's "type" attribute + * tells where to find additional type-specific data. + */ +public abstract class HistoryItem extends IdEntity { + public static final String TYPE_TEXT = "TEXT"; + + private final long historyId; + private final LocalDateTime timestamp; + private final String type; + + public HistoryItem(long id, long historyId, LocalDateTime timestamp, String type) { + super(id); + this.historyId = historyId; + this.timestamp = timestamp; + this.type = type; + } + + public long getHistoryId() { + return historyId; + } + + public LocalDateTime getTimestamp() { + return timestamp; + } + + public String getType() { + return type; + } +} diff --git a/src/main/java/com/andrewlalis/perfin/model/history/HistoryTextItem.java b/src/main/java/com/andrewlalis/perfin/model/history/HistoryTextItem.java new file mode 100644 index 0000000..d325286 --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/model/history/HistoryTextItem.java @@ -0,0 +1,16 @@ +package com.andrewlalis.perfin.model.history; + +import java.time.LocalDateTime; + +public class HistoryTextItem extends HistoryItem { + private final String description; + + public HistoryTextItem(long id, long historyId, LocalDateTime timestamp, String description) { + super(id, historyId, timestamp, HistoryItem.TYPE_TEXT); + this.description = description; + } + + public String getDescription() { + return description; + } +} diff --git a/src/main/java/com/andrewlalis/perfin/view/component/AccountHistoryAccountEntryTile.java b/src/main/java/com/andrewlalis/perfin/view/component/AccountHistoryAccountEntryTile.java deleted file mode 100644 index d78d42c..0000000 --- a/src/main/java/com/andrewlalis/perfin/view/component/AccountHistoryAccountEntryTile.java +++ /dev/null @@ -1,36 +0,0 @@ -package com.andrewlalis.perfin.view.component; - -import com.andrewlalis.perfin.control.TransactionsViewController; -import com.andrewlalis.perfin.data.AccountHistoryItemRepository; -import com.andrewlalis.perfin.data.util.CurrencyUtil; -import com.andrewlalis.perfin.model.AccountEntry; -import com.andrewlalis.perfin.model.history.AccountHistoryItem; -import javafx.scene.control.Hyperlink; -import javafx.scene.text.Text; -import javafx.scene.text.TextFlow; - -import static com.andrewlalis.perfin.PerfinApp.router; - -public class AccountHistoryAccountEntryTile extends AccountHistoryItemTile { - public AccountHistoryAccountEntryTile(AccountHistoryItem item, AccountHistoryItemRepository repo) { - super(item); - AccountEntry entry = repo.getAccountEntryItem(item.id); - if (entry == null) { - setCenter(new TextFlow(new Text("Deleted account entry because of deleted transaction."))); - return; - } - - Text amountText = new Text(CurrencyUtil.formatMoneyWithCurrencyPrefix(entry.getMoneyValue())); - Hyperlink transactionLink = new Hyperlink("Transaction #" + entry.getTransactionId()); - transactionLink.setOnAction(event -> router.navigate( - "transactions", - new TransactionsViewController.RouteContext(entry.getTransactionId()) - )); - var text = new TextFlow( - transactionLink, - new Text("posted as a " + entry.getType().name().toLowerCase() + " to this account, with a value of "), - amountText - ); - setCenter(text); - } -} diff --git a/src/main/java/com/andrewlalis/perfin/view/component/AccountHistoryBalanceRecordTile.java b/src/main/java/com/andrewlalis/perfin/view/component/AccountHistoryBalanceRecordTile.java deleted file mode 100644 index b263498..0000000 --- a/src/main/java/com/andrewlalis/perfin/view/component/AccountHistoryBalanceRecordTile.java +++ /dev/null @@ -1,31 +0,0 @@ -package com.andrewlalis.perfin.view.component; - -import com.andrewlalis.perfin.control.AccountViewController; -import com.andrewlalis.perfin.data.AccountHistoryItemRepository; -import com.andrewlalis.perfin.data.util.CurrencyUtil; -import com.andrewlalis.perfin.model.BalanceRecord; -import com.andrewlalis.perfin.model.history.AccountHistoryItem; -import javafx.scene.control.Hyperlink; -import javafx.scene.text.Text; -import javafx.scene.text.TextFlow; - -import static com.andrewlalis.perfin.PerfinApp.router; - -public class AccountHistoryBalanceRecordTile extends AccountHistoryItemTile { - public AccountHistoryBalanceRecordTile(AccountHistoryItem item, AccountHistoryItemRepository repo, AccountViewController controller) { - super(item); - BalanceRecord balanceRecord = repo.getBalanceRecordItem(item.id); - if (balanceRecord == null) { - setCenter(new TextFlow(new Text("Deleted balance record was added."))); - return; - } - - Text amountText = new Text(CurrencyUtil.formatMoneyWithCurrencyPrefix(balanceRecord.getMoneyAmount())); - var text = new TextFlow(new Text("Balance record #" + balanceRecord.id + " added with value of "), amountText); - setCenter(text); - - Hyperlink viewLink = new Hyperlink("View this balance record"); - viewLink.setOnAction(event -> router.navigate("balance-record", balanceRecord)); - setBottom(viewLink); - } -} diff --git a/src/main/java/com/andrewlalis/perfin/view/component/AccountHistoryItemTile.java b/src/main/java/com/andrewlalis/perfin/view/component/AccountHistoryItemTile.java index 9704d04..853bb7b 100644 --- a/src/main/java/com/andrewlalis/perfin/view/component/AccountHistoryItemTile.java +++ b/src/main/java/com/andrewlalis/perfin/view/component/AccountHistoryItemTile.java @@ -1,9 +1,8 @@ package com.andrewlalis.perfin.view.component; -import com.andrewlalis.perfin.control.AccountViewController; -import com.andrewlalis.perfin.data.AccountHistoryItemRepository; import com.andrewlalis.perfin.data.util.DateUtil; -import com.andrewlalis.perfin.model.history.AccountHistoryItem; +import com.andrewlalis.perfin.model.history.HistoryItem; +import com.andrewlalis.perfin.model.history.HistoryTextItem; import javafx.scene.control.Label; import javafx.scene.layout.BorderPane; @@ -11,7 +10,7 @@ import javafx.scene.layout.BorderPane; * A tile that shows a brief bit of information about an account history item. */ public abstract class AccountHistoryItemTile extends BorderPane { - public AccountHistoryItemTile(AccountHistoryItem item) { + public AccountHistoryItemTile(HistoryItem item) { getStyleClass().add("tile"); Label timestampLabel = new Label(DateUtil.formatUTCAsLocalWithZone(item.getTimestamp())); @@ -20,14 +19,11 @@ public abstract class AccountHistoryItemTile extends BorderPane { } public static AccountHistoryItemTile forItem( - AccountHistoryItem item, - AccountHistoryItemRepository repo, - AccountViewController controller + HistoryItem item ) { - return switch (item.getType()) { - case TEXT -> new AccountHistoryTextTile(item, repo); - case ACCOUNT_ENTRY -> new AccountHistoryAccountEntryTile(item, repo); - case BALANCE_RECORD -> new AccountHistoryBalanceRecordTile(item, repo, controller); - }; + if (item instanceof HistoryTextItem t) { + return new AccountHistoryTextTile(t); + } + throw new RuntimeException("Unsupported history item type: " + item.getType()); } } diff --git a/src/main/java/com/andrewlalis/perfin/view/component/AccountHistoryTextTile.java b/src/main/java/com/andrewlalis/perfin/view/component/AccountHistoryTextTile.java index 22f6c7d..2c7b8a2 100644 --- a/src/main/java/com/andrewlalis/perfin/view/component/AccountHistoryTextTile.java +++ b/src/main/java/com/andrewlalis/perfin/view/component/AccountHistoryTextTile.java @@ -1,14 +1,12 @@ package com.andrewlalis.perfin.view.component; -import com.andrewlalis.perfin.data.AccountHistoryItemRepository; -import com.andrewlalis.perfin.model.history.AccountHistoryItem; +import com.andrewlalis.perfin.model.history.HistoryTextItem; import javafx.scene.text.Text; import javafx.scene.text.TextFlow; public class AccountHistoryTextTile extends AccountHistoryItemTile { - public AccountHistoryTextTile(AccountHistoryItem item, AccountHistoryItemRepository repo) { + public AccountHistoryTextTile(HistoryTextItem item) { super(item); - String text = repo.getTextItem(item.id); - setCenter(new TextFlow(new Text(text))); + setCenter(new TextFlow(new Text(item.getDescription()))); } } diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java index 006df3f..766abce 100644 --- a/src/main/java/module-info.java +++ b/src/main/java/module-info.java @@ -19,4 +19,5 @@ module com.andrewlalis.perfin { opens com.andrewlalis.perfin.view to javafx.fxml; opens com.andrewlalis.perfin.view.component to javafx.fxml; opens com.andrewlalis.perfin.view.component.validation to javafx.fxml; + exports com.andrewlalis.perfin.model.history to javafx.graphics; } \ No newline at end of file diff --git a/src/main/resources/sql/migration/M002_RefactorHistories.sql b/src/main/resources/sql/migration/M002_RefactorHistories.sql new file mode 100644 index 0000000..789cdc2 --- /dev/null +++ b/src/main/resources/sql/migration/M002_RefactorHistories.sql @@ -0,0 +1,61 @@ +/* +Migration to clean up history entities so that they are easier to work with, and +less prone to errors. + +- Removes existing account history items. +- Adds a generic history table and history items that are linked to a history. +- Adds history links to accounts and transactions. +*/ + +CREATE TABLE history ( + id BIGINT PRIMARY KEY AUTO_INCREMENT +); + +CREATE TABLE history_item ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + history_id BIGINT NOT NULL, + timestamp TIMESTAMP NOT NULL, + type VARCHAR(63) NOT NULL, + CONSTRAINT fk_history_item_history + FOREIGN KEY (history_id) REFERENCES history(id) + ON UPDATE CASCADE ON DELETE CASCADE +); + +CREATE TABLE history_item_text ( + id BIGINT NOT NULL, + description VARCHAR(255) NOT NULL, + CONSTRAINT fk_history_item_text_pk + FOREIGN KEY (id) REFERENCES history_item(id) + ON UPDATE CASCADE ON DELETE CASCADE +); + +CREATE TABLE history_account ( + account_id BIGINT NOT NULL, + history_id BIGINT NOT NULL, + PRIMARY KEY (account_id, history_id), + CONSTRAINT fk_history_account_account + FOREIGN KEY (account_id) REFERENCES account(id) + ON UPDATE CASCADE ON DELETE CASCADE, + CONSTRAINT fk_history_account_history + FOREIGN KEY (history_id) REFERENCES history(id) + ON UPDATE CASCADE ON DELETE CASCADE +); + +CREATE TABLE history_transaction ( + transaction_id BIGINT NOT NULL, + history_id BIGINT NOT NULL, + PRIMARY KEY (transaction_id, history_id), + CONSTRAINT fk_history_transaction_transaction + FOREIGN KEY (transaction_id) REFERENCES transaction(id) + ON UPDATE CASCADE ON DELETE CASCADE, + CONSTRAINT fk_history_transaction_history + FOREIGN KEY (history_id) REFERENCES history(id) + ON UPDATE CASCADE ON DELETE CASCADE +); + +DROP TABLE IF EXISTS account_history_item_text; +DROP TABLE IF EXISTS account_history_item_account_entry; +DROP TABLE IF EXISTS account_history_item_balance_record; +DROP TABLE IF EXISTS account_history_item; + + diff --git a/src/main/resources/sql/schema.sql b/src/main/resources/sql/schema.sql index 17ff5f9..0f8a386 100644 --- a/src/main/resources/sql/schema.sql +++ b/src/main/resources/sql/schema.sql @@ -134,42 +134,49 @@ CREATE TABLE balance_record_attachment ( ON UPDATE CASCADE ON DELETE CASCADE ); -CREATE TABLE account_history_item ( +/* HISTORY */ +CREATE TABLE history ( + id BIGINT PRIMARY KEY AUTO_INCREMENT +); + +CREATE TABLE history_item ( id BIGINT PRIMARY KEY AUTO_INCREMENT, + history_id BIGINT NOT NULL, timestamp TIMESTAMP NOT NULL, - account_id BIGINT NOT NULL, type VARCHAR(63) NOT NULL, - CONSTRAINT fk_account_history_item_account - FOREIGN KEY (account_id) REFERENCES account(id) + CONSTRAINT fk_history_item_history + FOREIGN KEY (history_id) REFERENCES history(id) ON UPDATE CASCADE ON DELETE CASCADE ); -CREATE TABLE account_history_item_text ( - item_id BIGINT NOT NULL PRIMARY KEY, +CREATE TABLE history_item_text ( + id BIGINT PRIMARY KEY, description VARCHAR(255) NOT NULL, - CONSTRAINT fk_account_history_item_text_pk - FOREIGN KEY (item_id) REFERENCES account_history_item(id) + CONSTRAINT fk_history_item_text_pk + FOREIGN KEY (id) REFERENCES history_item(id) ON UPDATE CASCADE ON DELETE CASCADE ); -CREATE TABLE account_history_item_account_entry ( - item_id BIGINT NOT NULL PRIMARY KEY, - entry_id BIGINT NOT NULL, - CONSTRAINT fk_account_history_item_account_entry_pk - FOREIGN KEY (item_id) REFERENCES account_history_item(id) +CREATE TABLE history_account ( + account_id BIGINT NOT NULL, + history_id BIGINT NOT NULL, + PRIMARY KEY (account_id, history_id), + CONSTRAINT fk_history_account_account + FOREIGN KEY (account_id) REFERENCES account(id) ON UPDATE CASCADE ON DELETE CASCADE, - CONSTRAINT fk_account_history_item_account_entry - FOREIGN KEY (entry_id) REFERENCES account_entry(id) + CONSTRAINT fk_history_account_history + FOREIGN KEY (history_id) REFERENCES history(id) ON UPDATE CASCADE ON DELETE CASCADE ); -CREATE TABLE account_history_item_balance_record ( - item_id BIGINT NOT NULL PRIMARY KEY, - record_id BIGINT NOT NULL, - CONSTRAINT fk_account_history_item_balance_record_pk - FOREIGN KEY (item_id) REFERENCES account_history_item(id) +CREATE TABLE history_transaction ( + transaction_id BIGINT NOT NULL, + history_id BIGINT NOT NULL, + PRIMARY KEY (transaction_id, history_id), + CONSTRAINT fk_history_transaction_transaction + FOREIGN KEY (transaction_id) REFERENCES transaction(id) ON UPDATE CASCADE ON DELETE CASCADE, - CONSTRAINT fk_account_history_item_balance_record - FOREIGN KEY (record_id) REFERENCES balance_record(id) + CONSTRAINT fk_history_transaction_history + FOREIGN KEY (history_id) REFERENCES history(id) ON UPDATE CASCADE ON DELETE CASCADE );