Add Transaction Properties #15
|
@ -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<? extends Node> fetchPage(PageRequest pagination) throws Exception {
|
||||
Account accountFilter = filterByAccountComboBox.getValue();
|
||||
try (var repo = Profile.getCurrent().dataSource().getTransactionRepository()) {
|
||||
Page<Transaction> 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<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) {
|
||||
var tile = new TransactionTile(transaction);
|
||||
tile.setOnMouseClicked(event -> {
|
||||
|
|
|
@ -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);
|
||||
}
|
|
@ -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 {
|
||||
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -6,6 +6,7 @@
|
|||
<?import javafx.scene.control.Label?>
|
||||
<?import javafx.scene.control.ScrollPane?>
|
||||
<?import javafx.scene.layout.*?>
|
||||
<?import javafx.scene.control.TextField?>
|
||||
<BorderPane xmlns="http://javafx.com/javafx"
|
||||
xmlns:fx="http://javafx.com/fxml"
|
||||
fx:controller="com.andrewlalis.perfin.control.TransactionsViewController"
|
||||
|
@ -20,7 +21,8 @@
|
|||
<HBox>
|
||||
<BorderPane fx:id="transactionsListBorderPane" HBox.hgrow="ALWAYS">
|
||||
<top>
|
||||
<HBox styleClass="std-padding,std-spacing">
|
||||
<HBox styleClass="padding-extra,std-spacing">
|
||||
<TextField fx:id="searchField" promptText="Search"/>
|
||||
<PropertiesPane hgap="5" vgap="5">
|
||||
<Label text="Filter by Account"/>
|
||||
<AccountSelectionBox fx:id="filterByAccountComboBox" allowNone="true" showBalance="false"/>
|
||||
|
|
Loading…
Reference in New Issue