diff --git a/src/main/java/com/andrewlalis/perfin/control/TransactionsViewController.java b/src/main/java/com/andrewlalis/perfin/control/TransactionsViewController.java index 1de6d82..751ba51 100644 --- a/src/main/java/com/andrewlalis/perfin/control/TransactionsViewController.java +++ b/src/main/java/com/andrewlalis/perfin/control/TransactionsViewController.java @@ -3,9 +3,12 @@ package com.andrewlalis.perfin.control; import com.andrewlalis.javafx_scene_router.RouteSelectionListener; import com.andrewlalis.perfin.data.AccountRepository; import com.andrewlalis.perfin.data.TransactionRepository; +import com.andrewlalis.perfin.data.impl.JdbcDataSource; import com.andrewlalis.perfin.data.pagination.Page; import com.andrewlalis.perfin.data.pagination.PageRequest; import com.andrewlalis.perfin.data.pagination.Sort; +import com.andrewlalis.perfin.data.search.JdbcTransactionSearcher; +import com.andrewlalis.perfin.data.search.SearchFilter; import com.andrewlalis.perfin.data.util.DateUtil; import com.andrewlalis.perfin.data.util.Pair; import com.andrewlalis.perfin.model.Account; @@ -22,6 +25,7 @@ import javafx.beans.property.SimpleObjectProperty; import javafx.beans.value.ObservableValue; import javafx.fxml.FXML; import javafx.scene.Node; +import javafx.scene.control.TextField; import javafx.scene.layout.BorderPane; import javafx.scene.layout.HBox; import javafx.scene.layout.VBox; @@ -30,8 +34,9 @@ import javafx.stage.FileChooser; import java.io.File; import java.io.PrintWriter; import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; import java.util.List; -import java.util.Set; import static com.andrewlalis.perfin.PerfinApp.router; @@ -46,6 +51,7 @@ public class TransactionsViewController implements RouteSelectionListener { public record RouteContext(Long selectedTransactionId) {} @FXML public BorderPane transactionsListBorderPane; + @FXML public TextField searchField; @FXML public AccountSelectionBox filterByAccountComboBox; @FXML public VBox transactionsVBox; private DataSourcePaginationControls paginationControls; @@ -60,33 +66,30 @@ public class TransactionsViewController implements RouteSelectionListener { paginationControls.setPage(1); selectedTransaction.set(null); }); + searchField.textProperty().addListener((observable, oldValue, newValue) -> { + paginationControls.setPage(1); + selectedTransaction.set(null); + }); this.paginationControls = new DataSourcePaginationControls( transactionsVBox.getChildren(), new DataSourcePaginationControls.PageFetcherFunction() { @Override public Page fetchPage(PageRequest pagination) throws Exception { - Account accountFilter = filterByAccountComboBox.getValue(); - try (var repo = Profile.getCurrent().dataSource().getTransactionRepository()) { - Page result; - if (accountFilter == null) { - result = repo.findAll(pagination); - } else { - result = repo.findAllByAccounts(Set.of(accountFilter.id), pagination); - } - return result.map(TransactionsViewController.this::makeTile); + JdbcDataSource ds = (JdbcDataSource) Profile.getCurrent().dataSource(); + try (var conn = ds.getConnection()) { + JdbcTransactionSearcher searcher = new JdbcTransactionSearcher(conn); + return searcher.search(pagination, getCurrentSearchFilters()) + .map(TransactionsViewController.this::makeTile); } } @Override public int getTotalCount() throws Exception { - Account accountFilter = filterByAccountComboBox.getValue(); - try (var repo = Profile.getCurrent().dataSource().getTransactionRepository()) { - if (accountFilter == null) { - return (int) repo.countAll(); - } else { - return (int) repo.countAllByAccounts(Set.of(accountFilter.id)); - } + JdbcDataSource ds = (JdbcDataSource) Profile.getCurrent().dataSource(); + try (var conn = ds.getConnection()) { + JdbcTransactionSearcher searcher = new JdbcTransactionSearcher(conn); + return (int) searcher.resultCount(getCurrentSearchFilters()); } } } @@ -105,9 +108,7 @@ public class TransactionsViewController implements RouteSelectionListener { TransactionViewController transactionViewController = detailComponents.second(); BorderPane transactionDetailView = detailComponents.first(); detailPanel.getChildren().add(transactionDetailView); - selectedTransaction.addListener((observable, oldValue, newValue) -> { - transactionViewController.setTransaction(newValue); - }); + selectedTransaction.addListener((observable, oldValue, newValue) -> transactionViewController.setTransaction(newValue)); // Clear the transactions when a new profile is loaded. Profile.whenLoaded(profile -> { @@ -133,16 +134,17 @@ public class TransactionsViewController implements RouteSelectionListener { // If a transaction id is given in the route context, navigate to the page it's on and select it. if (context instanceof RouteContext ctx && ctx.selectedTransactionId != null) { - Profile.getCurrent().dataSource().useRepoAsync(TransactionRepository.class, repo -> { - repo.findById(ctx.selectedTransactionId).ifPresent(tx -> { - long offset = repo.countAllAfter(tx.id); - int pageNumber = (int) (offset / paginationControls.getItemsPerPage()) + 1; - Platform.runLater(() -> { - paginationControls.setPage(pageNumber); - selectedTransaction.set(tx); - }); - }); - }); + Profile.getCurrent().dataSource().useRepoAsync( + TransactionRepository.class, + repo -> repo.findById(ctx.selectedTransactionId).ifPresent(tx -> { + long offset = repo.countAllAfter(tx.id); + int pageNumber = (int) (offset / paginationControls.getItemsPerPage()) + 1; + Platform.runLater(() -> { + paginationControls.setPage(pageNumber); + selectedTransaction.set(tx); + }); + }) + ); } else { paginationControls.setPage(1); } @@ -180,6 +182,37 @@ public class TransactionsViewController implements RouteSelectionListener { } } + private List getCurrentSearchFilters() { + List filters = new ArrayList<>(); + if (searchField.getText() != null && !searchField.getText().isBlank()) { + var likeTerms = Arrays.stream(searchField.getText().strip().toLowerCase().split("\\s+")) + .map(t -> '%'+t+'%') + .toList(); + var builder = new SearchFilter.Builder(); + List orClauses = new ArrayList<>(likeTerms.size()); + for (var term : likeTerms) { + orClauses.add("LOWER(transaction.description) LIKE ? OR LOWER(sfv.name) LIKE ? OR LOWER(sfc.name) LIKE ?"); + builder.withArg(term); + builder.withArg(term); + builder.withArg(term); + } + builder.where(String.join(" OR ", orClauses)); + builder.withJoin("LEFT JOIN transaction_vendor sfv ON sfv.id = transaction.vendor_id"); + builder.withJoin("LEFT JOIN transaction_category sfc ON sfc.id = transaction.category_id"); + filters.add(builder.build()); + } + if (filterByAccountComboBox.getValue() != null) { + Account filteredAccount = filterByAccountComboBox.getValue(); + var filter = new SearchFilter.Builder() + .where("fae.account_id = ?") + .withArg(filteredAccount.id) + .withJoin("LEFT JOIN account_entry fae ON fae.transaction_id = transaction.id") + .build(); + filters.add(filter); + } + return filters; + } + private TransactionTile makeTile(Transaction transaction) { var tile = new TransactionTile(transaction); tile.setOnMouseClicked(event -> { diff --git a/src/main/java/com/andrewlalis/perfin/data/search/EntitySearcher.java b/src/main/java/com/andrewlalis/perfin/data/search/EntitySearcher.java new file mode 100644 index 0000000..ebc61c3 --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/data/search/EntitySearcher.java @@ -0,0 +1,28 @@ +package com.andrewlalis.perfin.data.search; + +import com.andrewlalis.perfin.data.pagination.Page; +import com.andrewlalis.perfin.data.pagination.PageRequest; + +import java.util.List; + +/** + * An entity searcher will search for entities matching a list of filters. + * @param The entity type to search over. + */ +public interface EntitySearcher { + /** + * Gets a page of results that match the given filters. + * @param pageRequest The page request. + * @param filters The filters to apply. + * @return A page of results. + */ + Page search(PageRequest pageRequest, List filters); + + /** + * Gets the number of results that would be returned for a given set of + * filters. + * @param filters The filters to apply. + * @return The number of entities that match. + */ + long resultCount(List filters); +} diff --git a/src/main/java/com/andrewlalis/perfin/data/search/JdbcEntitySearcher.java b/src/main/java/com/andrewlalis/perfin/data/search/JdbcEntitySearcher.java new file mode 100644 index 0000000..a1e9bbe --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/data/search/JdbcEntitySearcher.java @@ -0,0 +1,111 @@ +package com.andrewlalis.perfin.data.search; + +import com.andrewlalis.perfin.data.pagination.Page; +import com.andrewlalis.perfin.data.pagination.PageRequest; +import com.andrewlalis.perfin.data.util.Pair; +import com.andrewlalis.perfin.data.util.ResultSetMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +public class JdbcEntitySearcher implements EntitySearcher { + private static final Logger logger = LoggerFactory.getLogger(JdbcEntitySearcher.class); + + private final Connection conn; + private final String countExpression; + private final String selectExpression; + private final ResultSetMapper resultSetMapper; + + public JdbcEntitySearcher(Connection conn, String countExpression, String selectExpression, ResultSetMapper resultSetMapper) { + this.conn = conn; + this.countExpression = countExpression; + this.selectExpression = selectExpression; + this.resultSetMapper = resultSetMapper; + } + + private Pair>> buildSearchQuery(List filters) { + if (filters.isEmpty()) return new Pair<>("", Collections.emptyList()); + StringBuilder sb = new StringBuilder(); + List> args = new ArrayList<>(); + for (var filter : filters) { + args.addAll(filter.args()); + for (var joinClause : filter.joinClauses()) { + sb.append(joinClause).append('\n'); + } + } + sb.append("WHERE\n"); + for (int i = 0; i < filters.size(); i++) { + sb.append(filters.get(i).whereClause()); + if (i < filters.size() - 1) { + sb.append(" AND"); + } + sb.append('\n'); + } + return new Pair<>(sb.toString(), args); + } + + private void applyArgs(PreparedStatement stmt, List> args) throws SQLException { + for (int i = 1; i <= args.size(); i++) { + Pair arg = args.get(i - 1); + if (arg.second() == null) { + stmt.setNull(i, arg.first()); + } else { + stmt.setObject(i, arg.second(), arg.first()); + } + } + } + + @Override + public Page search(PageRequest pageRequest, List filters) { + var baseQueryAndArgs = buildSearchQuery(filters); + StringBuilder sqlBuilder = new StringBuilder(selectExpression); + if (baseQueryAndArgs.first() != null && !baseQueryAndArgs.first().isBlank()) { + sqlBuilder.append('\n').append(baseQueryAndArgs.first()); + } + String pagingSql = pageRequest.toSQL(); + if (pagingSql != null && !pagingSql.isBlank()) { + sqlBuilder.append('\n').append(pagingSql); + } + String sql = sqlBuilder.toString(); + logger.info("Searching with query:\n{}\nWith arguments: {}", sql, baseQueryAndArgs.second().stream().map(Pair::second).map(Object::toString).collect(Collectors.joining(", "))); + try (var stmt = conn.prepareStatement(sql)) { + applyArgs(stmt, baseQueryAndArgs.second()); + ResultSet rs = stmt.executeQuery(); + List results = new ArrayList<>(pageRequest.size()); + while (rs.next() && results.size() < pageRequest.size()) { + results.add(resultSetMapper.map(rs)); + } + return new Page<>(results, pageRequest); + } catch (SQLException e) { + logger.error("Search failed.", e); + return new Page<>(Collections.emptyList(), pageRequest); + } + } + + @Override + public long resultCount(List filters) { + var baseQueryAndArgs = buildSearchQuery(filters); + String sql = countExpression + "\n" + baseQueryAndArgs.first(); + try (var stmt = conn.prepareStatement(sql)) { + applyArgs(stmt, baseQueryAndArgs.second()); + ResultSet rs = stmt.executeQuery(); + if (!rs.next()) throw new SQLException("No count result."); + return rs.getLong(1); + } catch (SQLException e) { + logger.error("Failed to get search result count.", e); + return 0L; + } + } + + public static class Builder { + + } +} diff --git a/src/main/java/com/andrewlalis/perfin/data/search/JdbcTransactionSearcher.java b/src/main/java/com/andrewlalis/perfin/data/search/JdbcTransactionSearcher.java new file mode 100644 index 0000000..8f5c111 --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/data/search/JdbcTransactionSearcher.java @@ -0,0 +1,35 @@ +package com.andrewlalis.perfin.data.search; + +import com.andrewlalis.perfin.data.util.DbUtil; +import com.andrewlalis.perfin.model.Transaction; + +import java.math.BigDecimal; +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.time.LocalDateTime; +import java.util.Currency; + +public class JdbcTransactionSearcher extends JdbcEntitySearcher { + public JdbcTransactionSearcher(Connection conn) { + super( + conn, + "SELECT COUNT(transaction.id) FROM transaction", + "SELECT transaction.* FROM transaction", + JdbcTransactionSearcher::parseResultSet + ); + } + + private static Transaction parseResultSet(ResultSet rs) throws SQLException { + long id = rs.getLong(1); + LocalDateTime timestamp = DbUtil.utcLDTFromTimestamp(rs.getTimestamp(2)); + BigDecimal amount = rs.getBigDecimal(3); + Currency currency = Currency.getInstance(rs.getString(4)); + String description = rs.getString(5); + Long vendorId = rs.getLong(6); + if (rs.wasNull()) vendorId = null; + Long categoryId = rs.getLong(7); + if (rs.wasNull()) categoryId = null; + return new Transaction(id, timestamp, amount, currency, description, vendorId, categoryId); + } +} diff --git a/src/main/java/com/andrewlalis/perfin/data/search/SearchFilter.java b/src/main/java/com/andrewlalis/perfin/data/search/SearchFilter.java new file mode 100644 index 0000000..f69437a --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/data/search/SearchFilter.java @@ -0,0 +1,61 @@ +package com.andrewlalis.perfin.data.search; + +import com.andrewlalis.perfin.data.util.DbUtil; +import com.andrewlalis.perfin.data.util.Pair; + +import java.sql.Types; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public interface SearchFilter { + String whereClause(); + List> args(); + default List joinClauses() { + return Collections.emptyList(); + } + + record Impl(String whereClause, List> args, List joinClauses) implements SearchFilter {} + + class Builder { + private String whereClause; + private List> args = new ArrayList<>(); + private List joinClauses = new ArrayList<>(); + + public Builder where(String clause) { + this.whereClause = clause; + return this; + } + + public Builder withArg(int sqlType, Object value) { + args.add(new Pair<>(sqlType, value)); + return this; + } + + public Builder withArg(int value) { + return withArg(Types.INTEGER, value); + } + + public Builder withArg(long value) { + return withArg(Types.BIGINT, value); + } + + public Builder withArg(String value) { + return withArg(Types.VARCHAR, value); + } + + public Builder withArg(LocalDateTime utcTimestamp) { + return withArg(Types.TIMESTAMP, DbUtil.timestampFromUtcLDT(utcTimestamp)); + } + + public Builder withJoin(String joinClause) { + joinClauses.add(joinClause); + return this; + } + + public SearchFilter build() { + return new Impl(whereClause, args, joinClauses); + } + } +} diff --git a/src/main/resources/transactions-view.fxml b/src/main/resources/transactions-view.fxml index 75c699a..cc4fb89 100644 --- a/src/main/resources/transactions-view.fxml +++ b/src/main/resources/transactions-view.fxml @@ -6,6 +6,7 @@ + - + +