Add Transaction Properties #15

Merged
andrewlalis merged 18 commits from transaction-properties into main 2024-02-04 04:31:04 +00:00
6 changed files with 301 additions and 31 deletions
Showing only changes of commit 85627fb8ad - Show all commits

View File

@ -3,9 +3,12 @@ package com.andrewlalis.perfin.control;
import com.andrewlalis.javafx_scene_router.RouteSelectionListener; import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
import com.andrewlalis.perfin.data.AccountRepository; import com.andrewlalis.perfin.data.AccountRepository;
import com.andrewlalis.perfin.data.TransactionRepository; 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.Page;
import com.andrewlalis.perfin.data.pagination.PageRequest; import com.andrewlalis.perfin.data.pagination.PageRequest;
import com.andrewlalis.perfin.data.pagination.Sort; 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.DateUtil;
import com.andrewlalis.perfin.data.util.Pair; import com.andrewlalis.perfin.data.util.Pair;
import com.andrewlalis.perfin.model.Account; import com.andrewlalis.perfin.model.Account;
@ -22,6 +25,7 @@ import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.value.ObservableValue; import javafx.beans.value.ObservableValue;
import javafx.fxml.FXML; import javafx.fxml.FXML;
import javafx.scene.Node; import javafx.scene.Node;
import javafx.scene.control.TextField;
import javafx.scene.layout.BorderPane; import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox; import javafx.scene.layout.HBox;
import javafx.scene.layout.VBox; import javafx.scene.layout.VBox;
@ -30,8 +34,9 @@ import javafx.stage.FileChooser;
import java.io.File; import java.io.File;
import java.io.PrintWriter; import java.io.PrintWriter;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List; import java.util.List;
import java.util.Set;
import static com.andrewlalis.perfin.PerfinApp.router; import static com.andrewlalis.perfin.PerfinApp.router;
@ -46,6 +51,7 @@ public class TransactionsViewController implements RouteSelectionListener {
public record RouteContext(Long selectedTransactionId) {} public record RouteContext(Long selectedTransactionId) {}
@FXML public BorderPane transactionsListBorderPane; @FXML public BorderPane transactionsListBorderPane;
@FXML public TextField searchField;
@FXML public AccountSelectionBox filterByAccountComboBox; @FXML public AccountSelectionBox filterByAccountComboBox;
@FXML public VBox transactionsVBox; @FXML public VBox transactionsVBox;
private DataSourcePaginationControls paginationControls; private DataSourcePaginationControls paginationControls;
@ -60,33 +66,30 @@ public class TransactionsViewController implements RouteSelectionListener {
paginationControls.setPage(1); paginationControls.setPage(1);
selectedTransaction.set(null); selectedTransaction.set(null);
}); });
searchField.textProperty().addListener((observable, oldValue, newValue) -> {
paginationControls.setPage(1);
selectedTransaction.set(null);
});
this.paginationControls = new DataSourcePaginationControls( this.paginationControls = new DataSourcePaginationControls(
transactionsVBox.getChildren(), transactionsVBox.getChildren(),
new DataSourcePaginationControls.PageFetcherFunction() { new DataSourcePaginationControls.PageFetcherFunction() {
@Override @Override
public Page<? extends Node> fetchPage(PageRequest pagination) throws Exception { public Page<? extends Node> fetchPage(PageRequest pagination) throws Exception {
Account accountFilter = filterByAccountComboBox.getValue(); JdbcDataSource ds = (JdbcDataSource) Profile.getCurrent().dataSource();
try (var repo = Profile.getCurrent().dataSource().getTransactionRepository()) { try (var conn = ds.getConnection()) {
Page<Transaction> result; JdbcTransactionSearcher searcher = new JdbcTransactionSearcher(conn);
if (accountFilter == null) { return searcher.search(pagination, getCurrentSearchFilters())
result = repo.findAll(pagination); .map(TransactionsViewController.this::makeTile);
} else {
result = repo.findAllByAccounts(Set.of(accountFilter.id), pagination);
}
return result.map(TransactionsViewController.this::makeTile);
} }
} }
@Override @Override
public int getTotalCount() throws Exception { public int getTotalCount() throws Exception {
Account accountFilter = filterByAccountComboBox.getValue(); JdbcDataSource ds = (JdbcDataSource) Profile.getCurrent().dataSource();
try (var repo = Profile.getCurrent().dataSource().getTransactionRepository()) { try (var conn = ds.getConnection()) {
if (accountFilter == null) { JdbcTransactionSearcher searcher = new JdbcTransactionSearcher(conn);
return (int) repo.countAll(); return (int) searcher.resultCount(getCurrentSearchFilters());
} else {
return (int) repo.countAllByAccounts(Set.of(accountFilter.id));
}
} }
} }
} }
@ -105,9 +108,7 @@ public class TransactionsViewController implements RouteSelectionListener {
TransactionViewController transactionViewController = detailComponents.second(); TransactionViewController transactionViewController = detailComponents.second();
BorderPane transactionDetailView = detailComponents.first(); BorderPane transactionDetailView = detailComponents.first();
detailPanel.getChildren().add(transactionDetailView); detailPanel.getChildren().add(transactionDetailView);
selectedTransaction.addListener((observable, oldValue, newValue) -> { selectedTransaction.addListener((observable, oldValue, newValue) -> transactionViewController.setTransaction(newValue));
transactionViewController.setTransaction(newValue);
});
// Clear the transactions when a new profile is loaded. // Clear the transactions when a new profile is loaded.
Profile.whenLoaded(profile -> { 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 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) { if (context instanceof RouteContext ctx && ctx.selectedTransactionId != null) {
Profile.getCurrent().dataSource().useRepoAsync(TransactionRepository.class, repo -> { Profile.getCurrent().dataSource().useRepoAsync(
repo.findById(ctx.selectedTransactionId).ifPresent(tx -> { TransactionRepository.class,
long offset = repo.countAllAfter(tx.id); repo -> repo.findById(ctx.selectedTransactionId).ifPresent(tx -> {
int pageNumber = (int) (offset / paginationControls.getItemsPerPage()) + 1; long offset = repo.countAllAfter(tx.id);
Platform.runLater(() -> { int pageNumber = (int) (offset / paginationControls.getItemsPerPage()) + 1;
paginationControls.setPage(pageNumber); Platform.runLater(() -> {
selectedTransaction.set(tx); paginationControls.setPage(pageNumber);
}); selectedTransaction.set(tx);
}); });
}); })
);
} else { } else {
paginationControls.setPage(1); paginationControls.setPage(1);
} }
@ -180,6 +182,37 @@ public class TransactionsViewController implements RouteSelectionListener {
} }
} }
private List<SearchFilter> getCurrentSearchFilters() {
List<SearchFilter> 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<String> 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) { private TransactionTile makeTile(Transaction transaction) {
var tile = new TransactionTile(transaction); var tile = new TransactionTile(transaction);
tile.setOnMouseClicked(event -> { tile.setOnMouseClicked(event -> {

View File

@ -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 <T> The entity type to search over.
*/
public interface EntitySearcher<T> {
/**
* 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<T> search(PageRequest pageRequest, List<SearchFilter> 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<SearchFilter> filters);
}

View File

@ -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<T> implements EntitySearcher<T> {
private static final Logger logger = LoggerFactory.getLogger(JdbcEntitySearcher.class);
private final Connection conn;
private final String countExpression;
private final String selectExpression;
private final ResultSetMapper<T> resultSetMapper;
public JdbcEntitySearcher(Connection conn, String countExpression, String selectExpression, ResultSetMapper<T> resultSetMapper) {
this.conn = conn;
this.countExpression = countExpression;
this.selectExpression = selectExpression;
this.resultSetMapper = resultSetMapper;
}
private Pair<String, List<Pair<Integer, Object>>> buildSearchQuery(List<SearchFilter> filters) {
if (filters.isEmpty()) return new Pair<>("", Collections.emptyList());
StringBuilder sb = new StringBuilder();
List<Pair<Integer, Object>> 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<Pair<Integer, Object>> args) throws SQLException {
for (int i = 1; i <= args.size(); i++) {
Pair<Integer, Object> 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<T> search(PageRequest pageRequest, List<SearchFilter> 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<T> 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<SearchFilter> 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 {
}
}

View File

@ -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<Transaction> {
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);
}
}

View File

@ -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<Pair<Integer, Object>> args();
default List<String> joinClauses() {
return Collections.emptyList();
}
record Impl(String whereClause, List<Pair<Integer, Object>> args, List<String> joinClauses) implements SearchFilter {}
class Builder {
private String whereClause;
private List<Pair<Integer, Object>> args = new ArrayList<>();
private List<String> 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);
}
}
}

View File

@ -6,6 +6,7 @@
<?import javafx.scene.control.Label?> <?import javafx.scene.control.Label?>
<?import javafx.scene.control.ScrollPane?> <?import javafx.scene.control.ScrollPane?>
<?import javafx.scene.layout.*?> <?import javafx.scene.layout.*?>
<?import javafx.scene.control.TextField?>
<BorderPane xmlns="http://javafx.com/javafx" <BorderPane xmlns="http://javafx.com/javafx"
xmlns:fx="http://javafx.com/fxml" xmlns:fx="http://javafx.com/fxml"
fx:controller="com.andrewlalis.perfin.control.TransactionsViewController" fx:controller="com.andrewlalis.perfin.control.TransactionsViewController"
@ -20,7 +21,8 @@
<HBox> <HBox>
<BorderPane fx:id="transactionsListBorderPane" HBox.hgrow="ALWAYS"> <BorderPane fx:id="transactionsListBorderPane" HBox.hgrow="ALWAYS">
<top> <top>
<HBox styleClass="std-padding,std-spacing"> <HBox styleClass="padding-extra,std-spacing">
<TextField fx:id="searchField" promptText="Search"/>
<PropertiesPane hgap="5" vgap="5"> <PropertiesPane hgap="5" vgap="5">
<Label text="Filter by Account"/> <Label text="Filter by Account"/>
<AccountSelectionBox fx:id="filterByAccountComboBox" allowNone="true" showBalance="false"/> <AccountSelectionBox fx:id="filterByAccountComboBox" allowNone="true" showBalance="false"/>