Add FQL and rework /transaction endpoint

This commit is contained in:
2020-03-14 23:44:43 +01:00
parent 1cc7fdf052
commit d4fef75903
85 changed files with 2257 additions and 472 deletions

View File

@@ -0,0 +1,103 @@
grammar FQL;
/*
* Parser rules
*/
query
: (orExpression | orderByExpression EOF)* ;
orExpression
: andExpression (OR andExpression)* ;
andExpression
: expression (AND expression)* ;
expression
: (regularExpression | betweenExpression | periodExpression)
| parenthesisExpression ;
parenthesisExpression
: L_PAREN expr=orExpression R_PAREN ;
orderByExpression
: ORDER_BY field=IDENTIFIER order=(DESC | ASC ) ;
// regular expressions - NOT regex
regularExpression
: (stringExpression | intExpression | booleanExpression | dateExpression) ;
stringExpression
: field=IDENTIFIER operator=STRING_OPERATOR value=STRING_VALUE ;
intExpression
: field=IDENTIFIER operator=(INT_OPERATOR | STRING_OPERATOR) value=INT_VALUE ;
booleanExpression
: field=IDENTIFIER operator=STRING_OPERATOR value=(TRUE | FALSE) ;
dateExpression
: field=IDENTIFIER operator=STRING_OPERATOR value=DATE_VALUE ;
// BETWEEN expressions
betweenExpression
: (betweenStringExpression | betweenIntExpression ) ;
betweenStringExpression
: field=IDENTIFIER BETWEEN left=STRING_VALUE AND right=STRING_VALUE ;
betweenIntExpression
: field=IDENTIFIER BETWEEN left=INT_VALUE AND right=INT_VALUE ;
// period expressions
periodExpression
: periodConstExpression ;
periodConstExpression
: field=IDENTIFIER operator=STRING_OPERATOR value=(CURRENT |
LAST |
CURRENT_YEAR |
LAST_YEAR |
GRAND_TOTAL) ;
/*
* Lexer rules
*/
fragment B : ('B' | 'b') ;
fragment E : ('E' | 'e') ;
fragment T : ('T' | 't') ;
fragment W : ('W' | 'w') ;
fragment N : ('N' | 'n') ;
fragment A : ('A' | 'a') ;
fragment D : ('D' | 'd') ;
fragment C : ('C' | 'c') ;
fragment U : ('U' | 'u') ;
fragment R : ('R' | 'r') ;
fragment L : ('L' | 'l') ;
fragment S : ('S' | 's') ;
fragment Y : ('Y' | 'y') ;
fragment G : ('G' | 'g') ;
fragment O : ('O' | 'o') ;
fragment F : ('F' | 'f') ;
fragment SPACE : ' ' ;
// Keywords
BETWEEN : B E T W E E N ;
AND : A N D ;
OR : O R ;
ORDER_BY : O R D E R SPACE B Y ;
DESC : D E S C ;
ASC : A S C ;
TRUE : T R U E ;
FALSE : F A L S E ;
// Constant values
CURRENT : C U R R E N T ;
LAST : L A S T ;
YEAR : Y E A R ;
GRAND_TOTAL : G R A N D '_' T O T A L ;
CURRENT_YEAR : CURRENT '_' YEAR ;
LAST_YEAR : LAST '_' YEAR ;
STRING_OPERATOR : '=' ;
INT_OPERATOR : '>' | '<' | '>=' | '<=' | '!=' ;
L_PAREN : '(' ;
R_PAREN : ')' ;
IDENTIFIER : [a-zA-Z]+ ;
INT_VALUE : [0-9]+ ;
STRING_VALUE : '\'' [a-zA-Z0-9- ]+ '\'' ;
DATE_VALUE : [0-9-]+ ;
NEWLINE : ('\r'? '\n' | '\r')+ ;
WHITESPACE : ' ' -> skip;

View File

@@ -11,10 +11,10 @@ import java.util.List;
public class V22_0_1__calculateAccountStatistics extends BaseJavaMigration {
private static class TransactionPeriodAccountsContainer {
public Long transactionAmount;
public Long expensePeriodId;
public Long fromAccountId;
public Long toAccountId;
public final Long transactionAmount;
public final Long expensePeriodId;
public final Long fromAccountId;
public final Long toAccountId;
public TransactionPeriodAccountsContainer(Long transactionAmount, Long expensePeriodId, Long fromAccountId, Long toAccountId) {
this.transactionAmount = transactionAmount;

View File

@@ -12,10 +12,10 @@ import java.util.List;
public class V26_0_0__createGrandTotalPeriod extends BaseJavaMigration {
private static class TransactionAccountsContainer {
public Long transactionAmount;
public Long fromAccountId;
public Long toAccountId;
public Long id;
public final Long transactionAmount;
public final Long fromAccountId;
public final Long toAccountId;
public final Long id;
public TransactionAccountsContainer(Long transactionAmount, Long fromAccountId, Long toAccountId, Long id) {
this.transactionAmount = transactionAmount;

View File

@@ -14,10 +14,10 @@ public class V26_0_1__createExpenseYearPeriod_2019 extends BaseJavaMigration {
private static class TransactionAccountsContainer {
public Long transactionAmount;
public Long fromAccountId;
public Long toAccountId;
public Long id;
public final Long transactionAmount;
public final Long fromAccountId;
public final Long toAccountId;
public final Long id;
public TransactionAccountsContainer(Long transactionAmount, Long fromAccountId, Long toAccountId, Long id) {
this.transactionAmount = transactionAmount;
this.fromAccountId = fromAccountId;

View File

@@ -6,7 +6,6 @@ import org.flywaydb.core.api.migration.Context;
import java.sql.*;
import java.time.ZoneOffset;
import java.time.temporal.ChronoUnit;
import java.time.temporal.TemporalField;
import java.util.ArrayList;
import java.util.List;
@@ -14,10 +13,10 @@ public class V26_0_2__createExpenseYearPeriod_2020 extends BaseJavaMigration {
private static class TransactionAccountsContainer {
public Long transactionAmount;
public Long fromAccountId;
public Long toAccountId;
public Long id;
public final Long transactionAmount;
public final Long fromAccountId;
public final Long toAccountId;
public final Long id;
public TransactionAccountsContainer(Long transactionAmount, Long fromAccountId, Long toAccountId, Long id) {
this.transactionAmount = transactionAmount;
this.fromAccountId = fromAccountId;

View File

@@ -1,45 +0,0 @@
package de.financer;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
public enum ResponseReason {
OK(HttpStatus.OK),
UNKNOWN_ERROR(HttpStatus.INTERNAL_SERVER_ERROR),
INVALID_ACCOUNT_TYPE(HttpStatus.BAD_REQUEST),
FROM_ACCOUNT_NOT_FOUND(HttpStatus.BAD_REQUEST),
TO_ACCOUNT_NOT_FOUND(HttpStatus.BAD_REQUEST),
FROM_AND_TO_ACCOUNT_NOT_FOUND(HttpStatus.BAD_REQUEST),
INVALID_DATE_FORMAT(HttpStatus.BAD_REQUEST),
MISSING_DATE(HttpStatus.BAD_REQUEST),
AMOUNT_ZERO(HttpStatus.BAD_REQUEST),
MISSING_AMOUNT(HttpStatus.BAD_REQUEST),
INVALID_BOOKING_ACCOUNTS(HttpStatus.BAD_REQUEST),
MISSING_HOLIDAY_WEEKEND_TYPE(HttpStatus.BAD_REQUEST),
INVALID_HOLIDAY_WEEKEND_TYPE(HttpStatus.BAD_REQUEST),
MISSING_INTERVAL_TYPE(HttpStatus.BAD_REQUEST),
INVALID_INTERVAL_TYPE(HttpStatus.BAD_REQUEST),
MISSING_FIRST_OCCURRENCE(HttpStatus.BAD_REQUEST),
INVALID_FIRST_OCCURRENCE_FORMAT(HttpStatus.BAD_REQUEST),
INVALID_LAST_OCCURRENCE_FORMAT(HttpStatus.BAD_REQUEST),
MISSING_RECURRING_TRANSACTION_ID(HttpStatus.BAD_REQUEST),
INVALID_RECURRING_TRANSACTION_ID(HttpStatus.BAD_REQUEST),
RECURRING_TRANSACTION_NOT_FOUND(HttpStatus.BAD_REQUEST),
MISSING_TRANSACTION_ID(HttpStatus.BAD_REQUEST),
INVALID_TRANSACTION_ID(HttpStatus.BAD_REQUEST),
TRANSACTION_NOT_FOUND(HttpStatus.BAD_REQUEST),
ACCOUNT_NOT_FOUND(HttpStatus.BAD_REQUEST),
DUPLICATE_ACCOUNT_KEY(HttpStatus.BAD_REQUEST),
DUPLICATE_ACCOUNT_GROUP_NAME(HttpStatus.BAD_REQUEST),
ACCOUNT_GROUP_NOT_FOUND(HttpStatus.BAD_REQUEST);
private HttpStatus httpStatus;
ResponseReason(HttpStatus httpStatus) {
this.httpStatus = httpStatus;
}
public ResponseEntity toResponseEntity() {
return new ResponseEntity<>(this.name(), this.httpStatus);
}
}

View File

@@ -6,7 +6,6 @@ import org.slf4j.LoggerFactory;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
import javax.management.ReflectionException;
import java.time.format.DateTimeFormatter;
import java.util.Arrays;
import java.util.Collection;
@@ -68,10 +67,10 @@ public class FinancerConfig {
/**
* @return the date format used in e.g. the {@link de.financer.service.TransactionService#createTransaction(String,
* String, Long, String, String) TransactionService#createTransaction} or {@link
* String, Long, String, String, Boolean)} TransactionService#createTransaction} or {@link
* de.financer.service.RecurringTransactionService#createRecurringTransaction(String, String, Long, String, String,
* String, String, String) RecurringTransactionService#createRecurringTransaction} methods. Used to parse the
* client-supplied date string to proper {@link java.time.LocalDate LocalDate} objects
* String, String, String, Boolean, Boolean)} RecurringTransactionService#createRecurringTransaction} methods. Used
* to parse the client-supplied date string to proper {@link java.time.LocalDate LocalDate} objects
*/
public String getDateFormat() {
return dateFormat;

View File

@@ -11,7 +11,7 @@ public class ControllerUtil {
* @param toDecode the string to decode
* @return the decoded string in UTF-8 or, if UTF-8 is not available for whatever reason, the encoded string
*/
public static final String urlDecode(String toDecode) {
public static String urlDecode(String toDecode) {
try {
return URLDecoder.decode(toDecode, StandardCharsets.UTF_8.name());
}

View File

@@ -10,8 +10,6 @@ import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.Optional;
@RestController

View File

@@ -2,125 +2,160 @@ package de.financer.controller;
import de.financer.ResponseReason;
import de.financer.dto.ExpensePeriodTotal;
import de.financer.model.Transaction;
import de.financer.dto.SaveTransactionRequestDto;
import de.financer.dto.SearchTransactionsResponseDto;
import de.financer.service.TransactionService;
import de.financer.service.parameter.SearchTransactionsParameter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("transactions")
public class TransactionController {
private static final Logger LOGGER = LoggerFactory.getLogger(TransactionController.class);
@Autowired
private TransactionService transactionService;
@RequestMapping("getAll")
public Iterable<Transaction> getAll() {
return this.transactionService.getAll();
}
@RequestMapping("getAllForAccount")
public Iterable<Transaction> getAllForAccount(String accountKey) {
final String decoded = ControllerUtil.urlDecode(accountKey);
@GetMapping("/transactionsByFql")
public Iterable<SearchTransactionsResponseDto> searchTransactions(String fql) {
final String fqlDecoded = ControllerUtil.urlDecode(fql);
if (LOGGER.isDebugEnabled()) {
LOGGER.debug(String.format("/transactions/getAllForAccount got parameter: %s", decoded));
LOGGER.debug(String.format("GET /transactionsByFql/ got parameter: %s", fqlDecoded));
}
return this.transactionService.getAllForAccount(decoded);
}
@RequestMapping(value = "createTransaction")
public ResponseEntity createTransaction(String fromAccountKey, String toAccountKey, Long amount, String date,
String description, Boolean taxRelevant
) {
final String decodedFrom = ControllerUtil.urlDecode(fromAccountKey);
final String decodedTo = ControllerUtil.urlDecode(toAccountKey);
final String decodedDesc = ControllerUtil.urlDecode(description);
final Iterable<SearchTransactionsResponseDto> transactionSearchResponse =
this.transactionService.searchTransactions(fqlDecoded);
if (LOGGER.isDebugEnabled()) {
LOGGER.debug(String
.format("/transactions/createTransaction got parameters: %s, %s, %s, %s, %s, %s",
decodedFrom, decodedTo, amount, date, decodedDesc, taxRelevant));
LOGGER.debug(String.format("GET /transactionsByFql returns with %s", transactionSearchResponse));
}
return transactionSearchResponse;
}
@GetMapping("/transactions")
public Iterable<SearchTransactionsResponseDto> searchTransactions(String fromAccountKey,
String toAccountKey,
String periodId,
String limit,
String order,
String taxRelevant,
String accountsAnd) {
final String fromDecoded = ControllerUtil.urlDecode(fromAccountKey);
final String toDecoded = ControllerUtil.urlDecode(toAccountKey);
if (LOGGER.isDebugEnabled()) {
LOGGER.debug(String.format("GET /transactions/ got parameter: %s, %s, %s, %s, %s, %s - with order %s",
fromDecoded, toDecoded, periodId, limit, taxRelevant, accountsAnd, order));
}
// Wrap the url parameters into a proper POJO so the service parameter list wont get polluted
// There is no validation taking place here, only the wrapping. Validation happens in the service.
final SearchTransactionsParameter parameter = new SearchTransactionsParameter();
parameter.setFromAccountKey(fromDecoded);
parameter.setToAccountKey(toDecoded);
parameter.setLimit(limit);
parameter.setPeriodId(periodId);
parameter.setOrder(order);
parameter.setTaxRelevant(taxRelevant);
parameter.setAccountsAnd(accountsAnd);
final Iterable<SearchTransactionsResponseDto> transactionSearchResponse =
this.transactionService.searchTransactions(parameter);
if (LOGGER.isDebugEnabled()) {
LOGGER.debug(String.format("GET /transactions returns with %s", transactionSearchResponse));
}
return transactionSearchResponse;
}
@PostMapping(value = "/transactions")
public ResponseEntity createTransaction(@RequestBody SaveTransactionRequestDto requestDto) {
if (LOGGER.isDebugEnabled()) {
LOGGER.debug(String.format("POST /transactions got parameters: %s", requestDto));
}
final ResponseReason responseReason = this.transactionService
.createTransaction(decodedFrom, decodedTo, amount, date, decodedDesc, taxRelevant);
.createTransaction(requestDto.getFromAccountKey(),
requestDto.getToAccountKey(),
//TODO Conversion should be moved to
//TODO service and cause a proper response
//TODO if the value is not numeric
Long.valueOf(requestDto.getAmount()),
requestDto.getDate(),
requestDto.getDescription(),
requestDto.getTaxRelevant());
if (LOGGER.isDebugEnabled()) {
LOGGER.debug(String.format("/transactions/createTransaction returns with %s", responseReason.name()));
LOGGER.debug(String.format("POST /transactions returns with %s", responseReason.name()));
}
return responseReason.toResponseEntity();
}
@RequestMapping("deleteTransaction")
public ResponseEntity deleteTransaction(String transactionId) {
@DeleteMapping("/transactions/{transactionId}")
public ResponseEntity deleteTransaction(@PathVariable String transactionId) {
if (LOGGER.isDebugEnabled()) {
LOGGER.debug(String
.format("/transactions/deleteTransaction got parameters: %s",
transactionId));
LOGGER.debug(String.format("DELETE /transactions/{transactionId} got parameters: %s", transactionId));
}
final ResponseReason responseReason = this.transactionService
.deleteTransaction(transactionId);
final ResponseReason responseReason = this.transactionService.deleteTransaction(transactionId);
if (LOGGER.isDebugEnabled()) {
LOGGER.debug(String
.format("/transactions/deleteTransaction returns with %s", responseReason.name()));
LOGGER.debug(String.format("DELETE /transactions/{transactionId} returns with %s", responseReason.name()));
}
return responseReason.toResponseEntity();
}
@RequestMapping("getExpensesCurrentPeriod")
@GetMapping("/transactions/getExpensesCurrentPeriod")
public Long getExpensesCurrentPeriod() {
if (LOGGER.isDebugEnabled()) {
LOGGER.debug(String.format("/transactions/getExpensesCurrentPeriod called"));
LOGGER.debug(String.format("GET /transactions/getExpensesCurrentPeriod called"));
}
final Long response = this.transactionService.getExpensesCurrentPeriod();
if (LOGGER.isDebugEnabled()) {
LOGGER.debug(String.format("/transactions/getExpensesCurrentPeriod returns with %s", response));
LOGGER.debug(String.format("GET /transactions/getExpensesCurrentPeriod returns with %s", response));
}
return response;
}
@RequestMapping("getExpensePeriodTotals")
@GetMapping("/transactions/getExpensePeriodTotals")
public Iterable<ExpensePeriodTotal> getExpensePeriodTotals(Integer year) {
if (LOGGER.isDebugEnabled()) {
LOGGER.debug(String.format("/transactions/getExpensePeriodTotals got parameters: %s", year));
LOGGER.debug(String.format("GET /transactions/getExpensePeriodTotals got parameters: %s", year));
}
final Iterable<ExpensePeriodTotal> expensePeriodTotals = this.transactionService
.getExpensePeriodTotals(year);
final Iterable<ExpensePeriodTotal> expensePeriodTotals = this.transactionService.getExpensePeriodTotals(year);
if (LOGGER.isDebugEnabled()) {
LOGGER.debug(String.format("/transactions/getExpensePeriodTotals returns with %s", expensePeriodTotals));
LOGGER.debug(String.format("GET /transactions/getExpensePeriodTotals returns with %s", expensePeriodTotals));
}
return expensePeriodTotals;
}
@RequestMapping("getExpensesAllPeriods")
@GetMapping("/transactions/getExpensesAllPeriods")
public List<Long> getExpensesAllPeriods() {
if (LOGGER.isDebugEnabled()) {
LOGGER.debug(String.format("/transactions/getExpensesAllPeriods called"));
LOGGER.debug(String.format("GET /transactions/getExpensesAllPeriods called"));
}
final List<Long> response = this.transactionService.getExpensesAllPeriods();
if (LOGGER.isDebugEnabled()) {
LOGGER.debug(String.format("/transactions/getExpensesAllPeriods returns with %s", response));
LOGGER.debug(String.format("GET /transactions/getExpensesAllPeriods returns with %s", response));
}
return response;

View File

@@ -7,7 +7,6 @@ import org.springframework.data.repository.CrudRepository;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.time.LocalDateTime;
@Transactional(propagation = Propagation.REQUIRED)

View File

@@ -4,7 +4,6 @@ import de.financer.model.Account;
import de.financer.model.RecurringTransaction;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.CrudRepository;
import org.springframework.data.repository.query.Param;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

View File

@@ -7,11 +7,10 @@ import org.springframework.data.repository.CrudRepository;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.util.List;
@Transactional(propagation = Propagation.REQUIRED)
public interface TransactionRepository extends CrudRepository<Transaction, Long> {
public interface TransactionRepository extends CrudRepository<Transaction, Long>, TransactionRepositoryCustom {
@Query("SELECT t FROM Transaction t WHERE t.toAccount = :toAccount OR t.fromAccount = :fromAccount")
Iterable<Transaction> findTransactionsByFromAccountOrToAccount(Account fromAccount, Account toAccount);

View File

@@ -0,0 +1,18 @@
package de.financer.dba;
import de.financer.dto.Order;
import de.financer.dto.SearchTransactionsResponseDto;
import de.financer.model.Account;
import de.financer.model.Period;
public interface TransactionRepositoryCustom {
Iterable<SearchTransactionsResponseDto> searchTransactions(Period period,
Account fromAccount,
Account toAccount,
Integer limit,
Order order,
Boolean taxRelevant,
boolean accountsAnd);
Iterable<SearchTransactionsResponseDto> searchTransactions(String fql);
}

View File

@@ -0,0 +1,117 @@
package de.financer.dba.impl;
import de.financer.dba.TransactionRepositoryCustom;
import de.financer.dto.Order;
import de.financer.dto.SearchTransactionsResponseDto;
import de.financer.fql.FQLLexer;
import de.financer.fql.FQLParser;
import de.financer.fql.FQLVisitorImpl;
import de.financer.model.*;
import org.antlr.v4.runtime.CharStreams;
import org.antlr.v4.runtime.CommonTokenStream;
import org.springframework.stereotype.Repository;
import javax.persistence.EntityManager;
import javax.persistence.PersistenceContext;
import javax.persistence.TypedQuery;
import javax.persistence.criteria.*;
import java.util.ArrayList;
import java.util.List;
@Repository
public class TransactionRepositoryCustomImpl implements TransactionRepositoryCustom {
@PersistenceContext
private EntityManager entityManager;
@Override
public Iterable<SearchTransactionsResponseDto> searchTransactions(Period period,
Account fromAccount,
Account toAccount,
Integer limit,
Order order,
Boolean taxRelevant,
boolean accountsAnd) {
final CriteriaBuilder criteriaBuilder = this.entityManager.getCriteriaBuilder();
final CriteriaQuery<SearchTransactionsResponseDto> criteriaQuery = criteriaBuilder
.createQuery(SearchTransactionsResponseDto.class);
final Root<Transaction> fromTransaction = criteriaQuery.from(Transaction.class);
final List<Predicate> predicates = new ArrayList<>();
Predicate fromAccountPredicate = null;
Predicate toAccountPredicate = null;
if (taxRelevant != null) {
predicates.add(criteriaBuilder.equal(fromTransaction.get(Transaction_.taxRelevant), taxRelevant));
}
if (fromAccount != null) {
fromAccountPredicate = criteriaBuilder.equal(fromTransaction.get(Transaction_.fromAccount), fromAccount);
}
if (toAccount != null) {
toAccountPredicate = criteriaBuilder.equal(fromTransaction.get(Transaction_.toAccount), toAccount);
}
if (period != null) {
final SetJoin<Transaction, Period> periodJoin = fromTransaction.join(Transaction_.periods);
predicates.add(criteriaBuilder.equal(periodJoin.get(Period_.id), period.getId()));
}
if (fromAccountPredicate != null && toAccountPredicate != null) {
if (accountsAnd) {
predicates.add(criteriaBuilder.and(fromAccountPredicate, toAccountPredicate));
}
else {
predicates.add(criteriaBuilder.or(fromAccountPredicate, toAccountPredicate));
}
}
else if (fromAccountPredicate != null) {
predicates.add(fromAccountPredicate);
}
else if (toAccountPredicate != null) {
predicates.add(toAccountPredicate);
}
// else: both null, nothing to do
criteriaQuery.where(predicates.toArray(new Predicate[]{}));
switch(order) {
case TRANSACTIONS_BY_DATE_DESC:
// either leave case as last before default if new cases arrive
// or copy the expression
default:
criteriaQuery.orderBy(criteriaBuilder.desc(fromTransaction.get(Transaction_.date)));
}
criteriaQuery.select(criteriaBuilder.construct(SearchTransactionsResponseDto.class,
fromTransaction.get(Transaction_.id),
fromTransaction.get(Transaction_.fromAccount),
fromTransaction.get(Transaction_.toAccount),
fromTransaction.get(Transaction_.date),
fromTransaction.get(Transaction_.description),
fromTransaction.get(Transaction_.amount),
fromTransaction.get(Transaction_.taxRelevant),
criteriaBuilder.isNotNull(fromTransaction.get(Transaction_.recurringTransaction))));
final TypedQuery<SearchTransactionsResponseDto> query = this.entityManager.createQuery(criteriaQuery);
if (limit != null) {
query.setMaxResults(limit);
}
return query.getResultList();
}
@Override
public Iterable<SearchTransactionsResponseDto> searchTransactions(String fql) {
final FQLLexer lexer = new FQLLexer(CharStreams.fromString(fql));
final FQLParser parser = new FQLParser(new CommonTokenStream(lexer));
final CriteriaBuilder criteriaBuilder = this.entityManager.getCriteriaBuilder();
final FQLVisitorImpl visitor = new FQLVisitorImpl(criteriaBuilder);
visitor.visitQuery(parser.query());
return this.entityManager.createQuery(visitor.getCriteriaQuery()).getResultList();
}
}

View File

@@ -0,0 +1,230 @@
package de.financer.fql;
import de.financer.dto.SearchTransactionsResponseDto;
import de.financer.fql.field_handler.BetweenIntHandler;
import de.financer.fql.field_handler.BetweenStringHandler;
import de.financer.fql.field_handler.PeriodConstHandler;
import de.financer.fql.join_handler.JoinKey;
import de.financer.fql.field_handler.IntHandler;
import de.financer.model.*;
import org.antlr.v4.runtime.tree.ParseTree;
import org.apache.commons.lang3.StringUtils;
import javax.persistence.criteria.*;
import java.util.*;
public class FQLVisitorImpl extends FQLBaseVisitor<Predicate> {
private final CriteriaBuilder criteriaBuilder;
private final CriteriaQuery<SearchTransactionsResponseDto> criteriaQuery;
private final Root<Transaction> criteriaRoot;
private final Map<JoinKey, From<?, ?>> froms;
public FQLVisitorImpl(CriteriaBuilder criteriaBuilder) {
this.criteriaBuilder = criteriaBuilder;
this.criteriaQuery = criteriaBuilder.createQuery(SearchTransactionsResponseDto.class);
this.criteriaRoot = criteriaQuery.from(Transaction.class);
this.froms = new HashMap<>();
this.froms.put(JoinKey.of(Transaction.class), this.criteriaRoot);
}
public CriteriaQuery<SearchTransactionsResponseDto> getCriteriaQuery() {
return this.criteriaQuery;
}
@Override
public Predicate visitQuery(FQLParser.QueryContext ctx) {
buildSelect();
final Predicate predicateTree = ctx.getChild(0).accept(this);
// ORDER BY
if (ctx.getChildCount() > 1) {
ctx.getChild(1).accept(this);
}
this.criteriaQuery.where(predicateTree);
return predicateTree;
}
private void buildSelect() {
criteriaQuery.select(criteriaBuilder.construct(SearchTransactionsResponseDto.class,
this.criteriaRoot.get(Transaction_.id),
this.criteriaRoot.get(Transaction_.fromAccount),
this.criteriaRoot.get(Transaction_.toAccount),
this.criteriaRoot.get(Transaction_.date),
this.criteriaRoot.get(Transaction_.description),
this.criteriaRoot.get(Transaction_.amount),
this.criteriaRoot.get(Transaction_.taxRelevant),
criteriaBuilder.isNotNull(this.criteriaRoot.get(Transaction_.recurringTransaction))));
}
@Override
public Predicate visitOrExpression(FQLParser.OrExpressionContext ctx) {
if (ctx.getChildCount() == 1) {
return ctx.getChild(0).accept(this);
} else {
final List<Predicate> predicates = new ArrayList<>();
for (int i = 0; i < ctx.getChildCount(); i++) {
ParseTree c = ctx.getChild(i);
Predicate p = c.accept(this);
if (p != null) {
predicates.add(p);
}
}
return this.criteriaBuilder.or(predicates.toArray(new Predicate[]{}));
}
}
@Override
public Predicate visitAndExpression(FQLParser.AndExpressionContext ctx) {
if (ctx.getChildCount() == 1) {
return ctx.getChild(0).accept(this);
} else {
final List<Predicate> predicates = new ArrayList<>();
for (int i = 0; i < ctx.getChildCount(); i++) {
ParseTree c = ctx.getChild(i);
Predicate p = c.accept(this);
if (p != null) {
predicates.add(p);
}
}
return this.criteriaBuilder.and(predicates.toArray(new Predicate[]{}));
}
}
@Override
public Predicate visitExpression(FQLParser.ExpressionContext ctx) {
return ctx.getChild(0).accept(this);
}
@Override
public Predicate visitParenthesisExpression(FQLParser.ParenthesisExpressionContext ctx) {
return ctx.expr.accept(this);
}
@Override
public Predicate visitRegularExpression(FQLParser.RegularExpressionContext ctx) {
return ctx.getChild(0).accept(this);
}
@Override
public Predicate visitBooleanExpression(FQLParser.BooleanExpressionContext ctx) {
final FieldMapping fieldMapping = FieldMapping.findByFieldName(ctx.field.getText());
final Boolean value = Boolean.parseBoolean(ctx.value.getText());
fieldMapping.getJoinHandler().apply(this.froms, fieldMapping);
return fieldMapping.getFieldHandler().apply(fieldMapping, this.froms, this.criteriaBuilder, value);
}
@Override
public Predicate visitStringExpression(FQLParser.StringExpressionContext ctx) {
final FieldMapping fieldMapping = FieldMapping.findByFieldName(ctx.field.getText());
fieldMapping.getJoinHandler().apply(this.froms, fieldMapping);
return fieldMapping.getFieldHandler()
.apply(fieldMapping, this.froms, this.criteriaBuilder, ctx.value.getText());
}
@Override
public Predicate visitIntExpression(FQLParser.IntExpressionContext ctx) {
final FieldMapping fieldMapping = FieldMapping.findByFieldName(ctx.field.getText());
final IntHandler.IntHandlerParameterContainer con = IntHandler.IntHandlerParameterContainer
.of(ctx.operator.getText(), ctx.value.getText());
fieldMapping.getJoinHandler().apply(this.froms, fieldMapping);
return fieldMapping.getFieldHandler().apply(fieldMapping, this.froms, this.criteriaBuilder, con);
}
@Override
public Predicate visitDateExpression(FQLParser.DateExpressionContext ctx) {
final FieldMapping fieldMapping = FieldMapping.findByFieldName(ctx.field.getText());
fieldMapping.getJoinHandler().apply(this.froms, fieldMapping);
return fieldMapping.getFieldHandler()
.apply(fieldMapping, this.froms, this.criteriaBuilder, ctx.value.getText());
}
@Override
public Predicate visitBetweenExpression(FQLParser.BetweenExpressionContext ctx) {
return ctx.getChild(0).accept(this);
}
@Override
public Predicate visitBetweenStringExpression(FQLParser.BetweenStringExpressionContext ctx) {
final FieldMapping fieldMapping = FieldMapping.findByFieldName(ctx.field.getText());
final BetweenStringHandler.BetweenStringHandlerParameterContainer con = BetweenStringHandler.BetweenStringHandlerParameterContainer
.of(ctx.left.getText(), ctx.right.getText());
fieldMapping.getJoinHandler().apply(this.froms, fieldMapping);
// Overwrite the actual field handler of the field
return new BetweenStringHandler().apply(fieldMapping, this.froms, this.criteriaBuilder, con);
}
@Override
public Predicate visitBetweenIntExpression(FQLParser.BetweenIntExpressionContext ctx) {
final FieldMapping fieldMapping = FieldMapping.findByFieldName(ctx.field.getText());
final BetweenIntHandler.BetweenIntHandlerParameterContainer con = BetweenIntHandler.BetweenIntHandlerParameterContainer
.of(ctx.left.getText(), ctx.right.getText());
fieldMapping.getJoinHandler().apply(this.froms, fieldMapping);
// Overwrite the actual field handler of the field
return new BetweenIntHandler().apply(fieldMapping, this.froms, this.criteriaBuilder, con);
}
@Override
public Predicate visitPeriodExpression(FQLParser.PeriodExpressionContext ctx) {
return ctx.getChild(0).accept(this);
}
@Override
public Predicate visitPeriodConstExpression(FQLParser.PeriodConstExpressionContext ctx) {
final FieldMapping fieldMapping = FieldMapping.findByFieldName(ctx.field.getText());
final PeriodConstHandler.PeriodConstHandlerParameterContainer con = PeriodConstHandler.PeriodConstHandlerParameterContainer
.of(ctx.value.getText(), this.criteriaQuery);
fieldMapping.getJoinHandler().apply(this.froms, fieldMapping);
return fieldMapping.getFieldHandler().apply(fieldMapping, this.froms, this.criteriaBuilder, con);
}
@Override
public Predicate visitOrderByExpression(FQLParser.OrderByExpressionContext ctx) {
final FieldMapping fieldMapping = FieldMapping.findByFieldName(ctx.field.getText());
final String orderValue = StringUtils.upperCase(ctx.order.getText());
Order order;
fieldMapping.getJoinHandler().apply(this.froms, fieldMapping);
switch (orderValue) {
case "DESC":
order = this.criteriaBuilder
.desc(this.froms.get(fieldMapping.getJoinKey()).get(fieldMapping.getAttributeName()));
break;
case "ASC":
order = this.criteriaBuilder
.asc(this.froms.get(fieldMapping.getJoinKey()).get(fieldMapping.getAttributeName()));
break;
default:
throw new IllegalArgumentException(String.format("Unknown order value: %s", ctx.order.getText()));
}
this.criteriaQuery.orderBy(order);
return null;
}
}

View File

@@ -0,0 +1,119 @@
package de.financer.fql;
import de.financer.fql.field_handler.*;
import de.financer.fql.join_handler.*;
import de.financer.model.*;
import java.util.Arrays;
public enum FieldMapping {
AMOUNT("amount", Transaction_.AMOUNT, Transaction.class, NoopJoinHandler.class, JoinKey
.of(Transaction.class), null, IntHandler.class),
PERIOD("period", Period_.ID, Period.class, PeriodJoinHandler.class, JoinKey.of(Period.class), null, PeriodConstHandler.class),
FROM_ACCOUNT("fromAccount", Account_.KEY, Account.class, FromAccountJoinHandler.class, JoinKey
.of(Account.class, "FROM"), null, StringHandler.class),
TO_ACCOUNT("toAccount", Account_.KEY, Account.class, ToAccountJoinHandler.class, JoinKey
.of(Account.class, "TO"), null, StringHandler.class),
FROM_ACCOUNT_GROUP("fromAccountGroup", AccountGroup_.NAME, AccountGroup.class,
AccountGroupJoinHandler.class, JoinKey.of(AccountGroup.class, "FROM"), FROM_ACCOUNT, StringHandler.class),
TO_ACCOUNT_GROUP("toAccountGroup", AccountGroup_.NAME, AccountGroup.class,
AccountGroupJoinHandler.class, JoinKey.of(AccountGroup.class, "TO"), TO_ACCOUNT, StringHandler.class),
DATE("date", Transaction_.DATE, Transaction.class, NoopJoinHandler.class, JoinKey.of(Transaction.class), null, DateHandler.class),
RECURRING("recurring", Transaction_.RECURRING_TRANSACTION, Transaction.class, NoopJoinHandler.class,
JoinKey.of(Transaction.class), null, NotNullSyntheticHandler.class),
TAX_RELEVANT("taxRelevant", Transaction_.TAX_RELEVANT, Transaction.class, NoopJoinHandler.class,
JoinKey.of(Transaction.class), null, BooleanHandler.class);
/**
* The name of the field as used in FQL
*/
private final String fieldName;
/**
* The name of the ORM field
*/
private final String attributeName;
/**
* The entity this field belongs to
*/
private final Class<?> entityClass;
/**
* The join handler responsible for constructing the join if the field is not part of the root entity
*/
private final Class<? extends JoinHandler> joinHandlerClass;
/**
* A synthetic key to uniquely identify the join required for this field
*/
private final JoinKey joinKey;
/**
* The field mapping this field depends on if the entity is not directly reachable from the root entity
*/
private final FieldMapping dependingJoin;
/**
* The handler for this FQL field handling the translation to a JPA criteria
*/
private final Class<? extends FieldHandler> fieldHandlerClass;
FieldMapping(String fieldName, String attributeName, Class<?> entityClass,
Class<? extends JoinHandler> joinHandlerClass, JoinKey joinKey,
FieldMapping dependingJoin, Class<? extends FieldHandler> fieldHandlerClass
) {
this.fieldName = fieldName;
this.attributeName = attributeName;
this.entityClass = entityClass;
this.joinHandlerClass = joinHandlerClass;
this.joinKey = joinKey;
this.dependingJoin = dependingJoin;
this.fieldHandlerClass = fieldHandlerClass;
}
public static FieldMapping findByFieldName(String fqlFieldName) {
return Arrays.stream(values()).filter(fm -> fm.fieldName.equalsIgnoreCase(fqlFieldName)).findFirst()
.orElseThrow(() -> new IllegalArgumentException("Unknown field: " + fqlFieldName));
}
public String getAttributeName() {
return attributeName;
}
public Class<?> getEntityClass() {
return entityClass;
}
public JoinHandler getJoinHandler() {
try {
return this.joinHandlerClass.getDeclaredConstructor().newInstance();
} catch (ReflectiveOperationException e) {
throw new IllegalStateException("No public, parameterless constructor found!");
}
}
public FieldHandler getFieldHandler() {
try {
return this.fieldHandlerClass.getDeclaredConstructor().newInstance();
} catch (ReflectiveOperationException e) {
throw new IllegalStateException("No public, parameterless constructor found!");
}
}
public JoinKey getJoinKey() {
return this.joinKey;
}
public FieldMapping getDependingJoin() {
return this.dependingJoin;
}
}

View File

@@ -0,0 +1,32 @@
package de.financer.fql.field_handler;
import de.financer.fql.FieldMapping;
import de.financer.fql.join_handler.JoinKey;
import javax.persistence.criteria.CriteriaBuilder;
import javax.persistence.criteria.From;
import javax.persistence.criteria.Predicate;
import java.util.Map;
public class BetweenIntHandler implements FieldHandler<BetweenIntHandler.BetweenIntHandlerParameterContainer> {
public static class BetweenIntHandlerParameterContainer {
private Long left;
private Long right;
public static BetweenIntHandlerParameterContainer of(String left, String right) {
final BetweenIntHandlerParameterContainer con = new BetweenIntHandlerParameterContainer();
con.left = Long.valueOf(left);
con.right = Long.valueOf(right);
return con;
}
}
@Override
public Predicate apply(FieldMapping fieldMapping, Map<JoinKey, From<?, ?>> froms, CriteriaBuilder criteriaBuilder, BetweenIntHandlerParameterContainer con) {
return criteriaBuilder
.between(froms.get(fieldMapping.getJoinKey()).get(fieldMapping.getAttributeName()), con.left, con.right);
}
}

View File

@@ -0,0 +1,38 @@
package de.financer.fql.field_handler;
import de.financer.fql.FieldMapping;
import de.financer.fql.join_handler.JoinKey;
import javax.persistence.criteria.CriteriaBuilder;
import javax.persistence.criteria.From;
import javax.persistence.criteria.Predicate;
import java.util.Map;
public class BetweenStringHandler implements FieldHandler<BetweenStringHandler.BetweenStringHandlerParameterContainer> {
public static class BetweenStringHandlerParameterContainer {
private String left;
private String right;
public static BetweenStringHandlerParameterContainer of(String left, String right) {
final BetweenStringHandlerParameterContainer con = new BetweenStringHandlerParameterContainer();
con.left = left;
con.right = right;
return con;
}
}
@Override
public Predicate apply(FieldMapping fieldMapping, Map<JoinKey, From<?, ?>> froms, CriteriaBuilder criteriaBuilder, BetweenStringHandlerParameterContainer con) {
return criteriaBuilder
.between(froms.get(fieldMapping.getJoinKey()).get(fieldMapping.getAttributeName()),
removeQuotes(con.left),
removeQuotes(con.right));
}
private String removeQuotes(String value) {
return value.replaceAll("'", "");
}
}

View File

@@ -0,0 +1,16 @@
package de.financer.fql.field_handler;
import de.financer.fql.FieldMapping;
import de.financer.fql.join_handler.JoinKey;
import javax.persistence.criteria.CriteriaBuilder;
import javax.persistence.criteria.From;
import javax.persistence.criteria.Predicate;
import java.util.Map;
public class BooleanHandler implements FieldHandler<Boolean> {
@Override
public Predicate apply(FieldMapping fieldMapping, Map<JoinKey, From<?, ?>> froms, CriteriaBuilder criteriaBuilder, Boolean value) {
return criteriaBuilder.equal(froms.get(fieldMapping.getJoinKey()).get(fieldMapping.getAttributeName()), value);
}
}

View File

@@ -0,0 +1,21 @@
package de.financer.fql.field_handler;
import de.financer.fql.FieldMapping;
import de.financer.fql.join_handler.JoinKey;
import javax.persistence.criteria.CriteriaBuilder;
import javax.persistence.criteria.From;
import javax.persistence.criteria.Predicate;
import java.time.LocalDate;
import java.util.Map;
public class DateHandler implements FieldHandler<String> {
@Override
public Predicate apply(FieldMapping fieldMapping, Map<JoinKey, From<?, ?>> froms, CriteriaBuilder criteriaBuilder, String value) {
return criteriaBuilder.equal(froms.get(fieldMapping.getJoinKey()).get(fieldMapping.getAttributeName()), parseDate(value));
}
private LocalDate parseDate(String value) {
return LocalDate.parse(value);
}
}

View File

@@ -0,0 +1,13 @@
package de.financer.fql.field_handler;
import de.financer.fql.FieldMapping;
import de.financer.fql.join_handler.JoinKey;
import javax.persistence.criteria.CriteriaBuilder;
import javax.persistence.criteria.From;
import javax.persistence.criteria.Predicate;
import java.util.Map;
public interface FieldHandler<T> {
Predicate apply(FieldMapping fieldMapping, Map<JoinKey, From<?, ?>> froms, CriteriaBuilder criteriaBuilder, T value);
}

View File

@@ -0,0 +1,55 @@
package de.financer.fql.field_handler;
import de.financer.fql.FieldMapping;
import de.financer.fql.join_handler.JoinKey;
import javax.persistence.criteria.CriteriaBuilder;
import javax.persistence.criteria.From;
import javax.persistence.criteria.Path;
import javax.persistence.criteria.Predicate;
import java.util.Map;
public class IntHandler implements FieldHandler<IntHandler.IntHandlerParameterContainer> {
public static class IntHandlerParameterContainer {
private String operator;
private Long value;
public static IntHandlerParameterContainer of(String operator, String value) {
IntHandlerParameterContainer con = new IntHandlerParameterContainer();
con.operator = operator;
con.value = Long.valueOf(value);
return con;
}
}
@Override
public Predicate apply(FieldMapping fieldMapping, Map<JoinKey, From<?, ?>> froms, CriteriaBuilder criteriaBuilder, IntHandlerParameterContainer con) {
final Path<Long> path = froms.get(fieldMapping.getJoinKey()).get(fieldMapping.getAttributeName());
Predicate retVal = null;
switch (con.operator) {
case "=":
retVal = criteriaBuilder.equal(path, con.value);
break;
case ">":
retVal = criteriaBuilder.gt(path, con.value);
break;
case "<":
retVal = criteriaBuilder.lt(path, con.value);
break;
case ">=":
retVal = criteriaBuilder.greaterThanOrEqualTo(path, con.value);
break;
case "<=":
retVal = criteriaBuilder.lessThanOrEqualTo(path, con.value);
break;
case "!=":
retVal = criteriaBuilder.notEqual(path, con.value);
break;
}
return retVal;
}
}

View File

@@ -0,0 +1,20 @@
package de.financer.fql.field_handler;
import de.financer.fql.FieldMapping;
import de.financer.fql.join_handler.JoinKey;
import javax.persistence.criteria.CriteriaBuilder;
import javax.persistence.criteria.From;
import javax.persistence.criteria.Predicate;
import java.util.Map;
public class NotNullSyntheticHandler implements FieldHandler<Boolean> {
@Override
public Predicate apply(FieldMapping fieldMapping, Map<JoinKey, From<?, ?>> froms, CriteriaBuilder criteriaBuilder, Boolean value) {
if (value) {
return criteriaBuilder.isNotNull(froms.get(fieldMapping.getJoinKey()).get(fieldMapping.getAttributeName()));
} else {
return criteriaBuilder.isNull(froms.get(fieldMapping.getJoinKey()).get(fieldMapping.getAttributeName()));
}
}
}

View File

@@ -0,0 +1,73 @@
package de.financer.fql.field_handler;
import de.financer.dto.SearchTransactionsResponseDto;
import de.financer.fql.FieldMapping;
import de.financer.fql.join_handler.JoinKey;
import de.financer.model.Period;
import de.financer.model.PeriodType;
import de.financer.model.Period_;
import org.apache.commons.lang3.StringUtils;
import javax.persistence.criteria.*;
import java.util.Map;
public class PeriodConstHandler implements FieldHandler<PeriodConstHandler.PeriodConstHandlerParameterContainer> {
public static class PeriodConstHandlerParameterContainer {
private String value;
private CriteriaQuery<SearchTransactionsResponseDto> criteriaQuery;
public static PeriodConstHandlerParameterContainer of(String value, CriteriaQuery<SearchTransactionsResponseDto> criteriaQuery) {
final PeriodConstHandlerParameterContainer con = new PeriodConstHandlerParameterContainer();
con.value = value;
con.criteriaQuery = criteriaQuery;
return con;
}
}
@Override
public Predicate apply(FieldMapping fieldMapping, Map<JoinKey, From<?, ?>> froms, CriteriaBuilder criteriaBuilder, PeriodConstHandlerParameterContainer con) {
final Subquery<Long> subquery = con.criteriaQuery.subquery(Long.class);
final Root<Period> subFrom = subquery.from(Period.class);
final String upperValue = StringUtils.upperCase(con.value);
subquery.select(subFrom.get(fieldMapping.getAttributeName()));
// Generate subselect for each period value
switch (upperValue) {
case "CURRENT":
subquery.where(criteriaBuilder.and(criteriaBuilder
.equal(subFrom.get(Period_.TYPE), PeriodType.EXPENSE), criteriaBuilder
.isNull(subFrom.get(Period_.END))));
break;
case "LAST":
subquery.where(criteriaBuilder.and(criteriaBuilder
.equal(subFrom.get(Period_.TYPE), PeriodType.EXPENSE), criteriaBuilder
.isNotNull(subFrom.get(Period_.END))));
// subquery.orderBy -> orderBy not supported by JPA criteria
// So we need to workaround this by misusing the autoincremented ID
subquery.select(criteriaBuilder.max(subFrom.get(fieldMapping.getAttributeName())));
break;
case "CURRENT_YEAR":
subquery.where(criteriaBuilder.and(criteriaBuilder
.equal(subFrom.get(Period_.TYPE), PeriodType.EXPENSE_YEAR), criteriaBuilder
.isNull(subFrom.get(Period_.END))));
break;
case "LAST_YEAR":
subquery.where(criteriaBuilder.and(criteriaBuilder
.equal(subFrom.get(Period_.TYPE), PeriodType.EXPENSE_YEAR), criteriaBuilder
.isNotNull(subFrom.get(Period_.END))));
subquery.select(criteriaBuilder.max(subFrom.get(fieldMapping.getAttributeName())));
break;
case "GRAND_TOTAL":
subquery.where(criteriaBuilder.equal(subFrom.get(Period_.TYPE), PeriodType.GRAND_TOTAL));
break;
}
return criteriaBuilder.equal(froms.get(fieldMapping.getJoinKey())
.get(fieldMapping.getAttributeName()), subquery);
}
}

View File

@@ -0,0 +1,21 @@
package de.financer.fql.field_handler;
import de.financer.fql.FieldMapping;
import de.financer.fql.join_handler.JoinKey;
import javax.persistence.criteria.CriteriaBuilder;
import javax.persistence.criteria.From;
import javax.persistence.criteria.Predicate;
import java.util.Map;
public class StringHandler implements FieldHandler<String> {
@Override
public Predicate apply(FieldMapping fieldMapping, Map<JoinKey, From<?, ?>> froms, CriteriaBuilder criteriaBuilder, String value) {
return criteriaBuilder
.equal(froms.get(fieldMapping.getJoinKey()).get(fieldMapping.getAttributeName()), removeQuotes(value));
}
private String removeQuotes(String value) {
return value.replaceAll("'", "");
}
}

View File

@@ -0,0 +1,32 @@
package de.financer.fql.join_handler;
import de.financer.fql.FieldMapping;
import de.financer.model.Account;
import de.financer.model.AccountGroup;
import de.financer.model.Account_;
import de.financer.model.Transaction;
import javax.persistence.criteria.From;
import javax.persistence.criteria.Join;
import java.util.Map;
public class AccountGroupJoinHandler implements JoinHandler {
@Override
public Void apply(Map<JoinKey, From<?, ?>> joinKeyFromMap, FieldMapping fieldMapping) {
final FieldMapping dependingJoin = fieldMapping.getDependingJoin();
if (dependingJoin != null) {
dependingJoin.getJoinHandler().apply(joinKeyFromMap, dependingJoin);
}
if (joinKeyFromMap.get(fieldMapping.getJoinKey()) == null) {
final Join<Account, AccountGroup> accountGroupJoin = ((Join<Transaction, Account>) joinKeyFromMap
.get(dependingJoin.getJoinKey()))
.join(Account_.accountGroup);
joinKeyFromMap.put(fieldMapping.getJoinKey(), accountGroupJoin);
}
return null;
}
}

View File

@@ -0,0 +1,26 @@
package de.financer.fql.join_handler;
import de.financer.fql.FieldMapping;
import de.financer.model.Account;
import de.financer.model.Transaction;
import de.financer.model.Transaction_;
import javax.persistence.criteria.From;
import javax.persistence.criteria.Join;
import javax.persistence.criteria.Root;
import java.util.Map;
public class FromAccountJoinHandler implements JoinHandler {
@Override
public Void apply(Map<JoinKey, From<?, ?>> joinKeyFromMap, FieldMapping fieldMapping) {
if (joinKeyFromMap.get(fieldMapping.getJoinKey()) == null) {
final Join<Transaction, Account> accountJoin = ((Root<Transaction>) joinKeyFromMap
.get(JoinKey.of(Transaction.class)))
.join(Transaction_.fromAccount);
joinKeyFromMap.put(fieldMapping.getJoinKey(), accountJoin);
}
return null;
}
}

View File

@@ -0,0 +1,10 @@
package de.financer.fql.join_handler;
import de.financer.fql.FieldMapping;
import javax.persistence.criteria.From;
import java.util.Map;
import java.util.function.BiFunction;
public interface JoinHandler extends BiFunction<Map<JoinKey, From<?, ?>>, FieldMapping, Void> {
}

View File

@@ -0,0 +1,36 @@
package de.financer.fql.join_handler;
import org.apache.commons.lang3.builder.EqualsBuilder;
import org.apache.commons.lang3.builder.HashCodeBuilder;
public class JoinKey {
private Class<?> clazz;
private String key;
public static JoinKey of(Class<?> clazz, String key) {
JoinKey jk = new JoinKey();
jk.clazz = clazz;
jk.key = key;
return jk;
}
public static JoinKey of(Class<?> clazz) {
JoinKey jk = new JoinKey();
jk.clazz = clazz;
return jk;
}
@Override
public boolean equals(Object obj) {
return EqualsBuilder.reflectionEquals(this, obj, false);
}
@Override
public int hashCode() {
return HashCodeBuilder.reflectionHashCode(this, false);
}
}

View File

@@ -0,0 +1,13 @@
package de.financer.fql.join_handler;
import de.financer.fql.FieldMapping;
import javax.persistence.criteria.From;
import java.util.Map;
public class NoopJoinHandler implements JoinHandler {
@Override
public Void apply(Map<JoinKey, From<?, ?>> joinKeyFromMap, FieldMapping fieldMapping) {
return null;
}
}

View File

@@ -0,0 +1,28 @@
package de.financer.fql.join_handler;
import de.financer.fql.FieldMapping;
import de.financer.model.Period;
import de.financer.model.Transaction;
import de.financer.model.Transaction_;
import javax.persistence.criteria.From;
import javax.persistence.criteria.Root;
import javax.persistence.criteria.SetJoin;
import java.util.Map;
public class PeriodJoinHandler implements JoinHandler {
@Override
public Void apply(Map<JoinKey, From<?, ?>> joinKeyFromMap, FieldMapping fieldMapping) {
if (joinKeyFromMap.get(fieldMapping.getJoinKey()) == null) {
final SetJoin<Transaction, Period> periodJoin = ((Root<Transaction>) joinKeyFromMap
.get(JoinKey.of(Transaction.class)))
.join(Transaction_.periods);
joinKeyFromMap.put(fieldMapping.getJoinKey(), periodJoin);
}
return null;
}
}

View File

@@ -0,0 +1,26 @@
package de.financer.fql.join_handler;
import de.financer.fql.FieldMapping;
import de.financer.model.Account;
import de.financer.model.Transaction;
import de.financer.model.Transaction_;
import javax.persistence.criteria.From;
import javax.persistence.criteria.Join;
import javax.persistence.criteria.Root;
import java.util.Map;
public class ToAccountJoinHandler implements JoinHandler {
@Override
public Void apply(Map<JoinKey, From<?, ?>> joinKeyFromMap, FieldMapping fieldMapping) {
if (joinKeyFromMap.get(fieldMapping.getJoinKey()) == null) {
final Join<Transaction, Account> accountJoin = ((Root<Transaction>) joinKeyFromMap
.get(JoinKey.of(Transaction.class)))
.join(Transaction_.toAccount);
joinKeyFromMap.put(fieldMapping.getJoinKey(), accountJoin);
}
return null;
}
}

View File

@@ -43,6 +43,7 @@ public class AccountService {
* @return the account or <code>null</code> if no account with the given key can be found
*/
public Account getAccountByKey(String key) {
// TODO change return type of repository to Optional<Account>
return this.accountRepository.findByKey(key);
}
@@ -102,7 +103,7 @@ public class AccountService {
// If we create an account it's implicitly open
account.setStatus(AccountStatus.OPEN);
// and has a current balance of 0
account.setCurrentBalance(Long.valueOf(0L));
account.setCurrentBalance(0L);
try {
this.accountRepository.save(account);

View File

@@ -12,10 +12,9 @@ import org.slf4j.LoggerFactory;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.Month;
import java.util.Optional;
@Service
public class PeriodService {
@@ -132,4 +131,8 @@ public class PeriodService {
return period;
}
public Optional<Period> getPeriodById(Long id) {
return this.periodRepository.findById(id);
}
}

View File

@@ -4,7 +4,12 @@ import de.financer.ResponseReason;
import de.financer.config.FinancerConfig;
import de.financer.dba.TransactionRepository;
import de.financer.dto.ExpensePeriodTotal;
import de.financer.dto.Order;
import de.financer.dto.SearchTransactionsResponseDto;
import de.financer.model.*;
import de.financer.service.exception.FinancerServiceException;
import de.financer.service.parameter.SearchTransactionsParameter;
import org.apache.commons.lang3.BooleanUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.math.NumberUtils;
import org.slf4j.Logger;
@@ -48,25 +53,6 @@ public class TransactionService {
return this.transactionRepository.findAll();
}
/**
* @param accountKey the key of the account to get the transactions for
*
* @return all transactions for the given account, for all time. Returns an empty list if the given key does not
* match any account.
*/
public Iterable<Transaction> getAllForAccount(String accountKey) {
final Account account = this.accountService.getAccountByKey(accountKey);
if (account == null) {
LOGGER.warn(String.format("Account with key %s not found!", accountKey));
return Collections.emptyList();
}
// As we want all transactions of the given account use it as from and to account
return this.transactionRepository.findTransactionsByFromAccountOrToAccount(account, account);
}
@Transactional(propagation = Propagation.REQUIRED)
public ResponseReason createTransaction(String fromAccountKey, String toAccountKey, Long amount, String date,
String description, Boolean taxRelevant
@@ -114,7 +100,7 @@ public class TransactionService {
this.accountService.saveAccount(fromAccount);
this.accountService.saveAccount(toAccount);
response = ResponseReason.OK;
response = ResponseReason.CREATED;
} catch (Exception e) {
LOGGER.error("Could not create transaction!", e);
@@ -261,7 +247,7 @@ public class TransactionService {
final Long expensesCurrentPeriod = this.transactionRepository
.getExpensesForPeriod(expensePeriod, AccountType.EXPENSE, AccountType.LIABILITY);
return Optional.ofNullable(expensesCurrentPeriod).orElse(Long.valueOf(0l));
return Optional.ofNullable(expensesCurrentPeriod).orElse(0L);
}
/**
@@ -290,4 +276,93 @@ public class TransactionService {
return this.transactionRepository
.getAccountExpenseTotals(periods, AccountType.INCOME, AccountType.START, AccountType.EXPENSE, AccountType.LIABILITY);
}
/**
* This method searches for transactions as specified with the given search parameters. If no parameters at all are
* given the search returns the top 500 transactions for all accounts ordered by the transaction date descending.
*
* @param parameter the encapsulated search parameters
* @return the list of transactions matching the given parameters or the top 500 transactions. Never <code>null</code>
*/
public Iterable<SearchTransactionsResponseDto> searchTransactions(SearchTransactionsParameter parameter) {
// Check that at least one bounding parameter is set so the resulting list won't be too large
if (StringUtils.isEmpty(parameter.getPeriodId()) && StringUtils.isEmpty(parameter.getLimit())) {
// 500 is pretty conservative, could probably be increased if required to at least 1000
parameter.setLimit("500");
}
final Order order;
Period period = null;
Integer limit = null;
Account fromAccount = null;
Account toAccount = null;
Boolean taxRelevant = null;
Boolean accountsAnd = Boolean.FALSE; // account values are OR conjunct by default
if (StringUtils.isNotEmpty(parameter.getPeriodId())) {
if (!NumberUtils.isCreatable(parameter.getPeriodId())) {
throw new FinancerServiceException(ResponseReason.PERIOD_ID_NOT_NUMERIC);
}
final Optional<Period> optionalPeriod = this.periodService.getPeriodById(Long.valueOf(parameter.getPeriodId()));
period = optionalPeriod.orElseThrow(() -> new FinancerServiceException(ResponseReason.PERIOD_NOT_FOUND));
}
if (StringUtils.isNotEmpty(parameter.getLimit())) {
if (!NumberUtils.isCreatable(parameter.getLimit())) {
throw new FinancerServiceException(ResponseReason.LIMIT_NOT_NUMERIC);
}
limit = Integer.valueOf(parameter.getLimit());
}
order = Order.getByName(parameter.getOrder()).orElse(Order.TRANSACTIONS_BY_DATE_DESC);
if (StringUtils.isNotEmpty(parameter.getFromAccountKey())) {
fromAccount = this.accountService.getAccountByKey(parameter.getFromAccountKey());
if (fromAccount == null) {
throw new FinancerServiceException(ResponseReason.FROM_ACCOUNT_NOT_FOUND);
}
}
if (StringUtils.isNotEmpty(parameter.getToAccountKey())) {
toAccount = this.accountService.getAccountByKey(parameter.getToAccountKey());
if (toAccount == null) {
throw new FinancerServiceException(ResponseReason.TO_ACCOUNT_NOT_FOUND);
}
}
if (StringUtils.isNotEmpty(parameter.getTaxRelevant())) {
taxRelevant = BooleanUtils.toBooleanObject(parameter.getTaxRelevant());
if (taxRelevant == null) {
throw new FinancerServiceException(ResponseReason.INVALID_TAX_RELEVANT_VALUE);
}
}
if (StringUtils.isNotEmpty(parameter.getAccountsAnd())) {
accountsAnd = BooleanUtils.toBooleanObject(parameter.getAccountsAnd());
if (accountsAnd == null) {
throw new FinancerServiceException(ResponseReason.INVALID_ACCOUNTS_AND_VALUE);
}
}
return this.transactionRepository.searchTransactions(period, fromAccount, toAccount, limit, order, taxRelevant,
accountsAnd);
}
public Iterable<SearchTransactionsResponseDto> searchTransactions(String fql) {
try {
return this.transactionRepository.searchTransactions(fql);
}
catch(IllegalArgumentException iae) {
LOGGER.error("Error while parsing FQL", iae);
throw new FinancerServiceException(ResponseReason.FQL_MALFORMED);
}
}
}

View File

@@ -0,0 +1,15 @@
package de.financer.service.exception;
import de.financer.ResponseReason;
public class FinancerServiceException extends RuntimeException {
private final ResponseReason responseReason;
public FinancerServiceException(ResponseReason responseReason) {
this.responseReason = responseReason;
}
public ResponseReason getResponseReason() {
return this.responseReason;
}
}

View File

@@ -0,0 +1,17 @@
package de.financer.service.exception;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
@ControllerAdvice
public class RestControllerExceptionAdvisor extends ResponseEntityExceptionHandler {
@ExceptionHandler(value = {FinancerServiceException.class})
public ResponseEntity<String> handleFinancerServiceException(FinancerServiceException ex, WebRequest request) {
return ex.getResponseReason().toResponseEntity();
}
}

View File

@@ -0,0 +1,67 @@
package de.financer.service.parameter;
public class SearchTransactionsParameter {
private String fromAccountKey;
private String toAccountKey;
private String periodId;
private String limit;
private String order;
private String taxRelevant;
private String accountsAnd;
public String getFromAccountKey() {
return fromAccountKey;
}
public void setFromAccountKey(String fromAccountKey) {
this.fromAccountKey = fromAccountKey;
}
public String getToAccountKey() {
return toAccountKey;
}
public void setToAccountKey(String toAccountKey) {
this.toAccountKey = toAccountKey;
}
public String getPeriodId() {
return periodId;
}
public void setPeriodId(String periodId) {
this.periodId = periodId;
}
public String getLimit() {
return limit;
}
public void setLimit(String limit) {
this.limit = limit;
}
public String getOrder() {
return order;
}
public void setOrder(String order) {
this.order = order;
}
public String getTaxRelevant() {
return taxRelevant;
}
public void setTaxRelevant(String taxRelevant) {
this.taxRelevant = taxRelevant;
}
public String getAccountsAnd() {
return accountsAnd;
}
public void setAccountsAnd(String accountsAnd) {
this.accountsAnd = accountsAnd;
}
}

View File

@@ -14,7 +14,6 @@ import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.util.Locale;
import java.util.stream.Collectors;
@Component
@@ -52,7 +51,7 @@ public class SendRecurringTransactionReminderTask {
// TODO Filtering currently happens in memory but should be done via SQL
recurringTransactions = IterableUtils.toList(recurringTransactions)
.stream()
.filter((rt) -> rt.isRemind())
.filter(RecurringTransaction::isRemind)
.collect(Collectors.toList());
LOGGER.info(String
@@ -65,20 +64,18 @@ public class SendRecurringTransactionReminderTask {
.append(System.lineSeparator())
.append(System.lineSeparator());
IterableUtils.toList(recurringTransactions).forEach((rt) -> {
reminderBuilder.append(rt.getId())
.append("|")
.append(rt.getDescription())
.append(System.lineSeparator())
.append(this.getMessage("financer.recurring-transaction.email.reminder.from"))
.append(rt.getFromAccount().getKey())
.append(this.getMessage("financer.recurring-transaction.email.reminder.to"))
.append(rt.getToAccount().getKey())
.append(": ")
.append(rt.getAmount().toString())
.append(System.lineSeparator())
.append(System.lineSeparator());
});
IterableUtils.toList(recurringTransactions).forEach((rt) -> reminderBuilder.append(rt.getId())
.append("|")
.append(rt.getDescription())
.append(System.lineSeparator())
.append(this.getMessage("financer.recurring-transaction.email.reminder.from"))
.append(rt.getFromAccount().getKey())
.append(this.getMessage("financer.recurring-transaction.email.reminder.to"))
.append(rt.getToAccount().getKey())
.append(": ")
.append(rt.getAmount().toString())
.append(System.lineSeparator())
.append(System.lineSeparator()));
final SimpleMailMessage msg = new SimpleMailMessage();