Mark categorization root rules with new field, fix boolean equality check

This commit is contained in:
Miroslav Vasilev 2023-12-30 20:47:11 +02:00
parent 3653dc5861
commit acfbe7033a
6 changed files with 27 additions and 12 deletions

View file

@ -22,6 +22,8 @@ public class Categorization extends AbstractEntity implements UserOwned {
@Convert(converter = CategorizationRule.JpaConverter.class) @Convert(converter = CategorizationRule.JpaConverter.class)
private CategorizationRule categorizationRule; private CategorizationRule categorizationRule;
private boolean isRoot;
private String stringValue; private String stringValue;
private Double numericGreaterThan; private Double numericGreaterThan;
@ -149,4 +151,12 @@ public class Categorization extends AbstractEntity implements UserOwned {
public void setRightCategorizationId(Long rightCategorizationId) { public void setRightCategorizationId(Long rightCategorizationId) {
this.rightCategorizationId = rightCategorizationId; this.rightCategorizationId = rightCategorizationId;
} }
public boolean isRoot() {
return isRoot;
}
public void setRoot(boolean root) {
isRoot = root;
}
} }

View file

@ -8,6 +8,7 @@ public enum CategorizationRule implements PersistableEnum<String> {
STRING_REGEX(RawTransactionValueType.STRING), STRING_REGEX(RawTransactionValueType.STRING),
STRING_EQ(RawTransactionValueType.STRING), STRING_EQ(RawTransactionValueType.STRING),
STRING_CONTAINS(RawTransactionValueType.STRING), STRING_CONTAINS(RawTransactionValueType.STRING),
STRING_IS_EMPTY(RawTransactionValueType.STRING),
NUMERIC_GREATER_THAN(RawTransactionValueType.NUMERIC), NUMERIC_GREATER_THAN(RawTransactionValueType.NUMERIC),
NUMERIC_LESS_THAN(RawTransactionValueType.NUMERIC), NUMERIC_LESS_THAN(RawTransactionValueType.NUMERIC),
NUMERIC_EQUALS(RawTransactionValueType.NUMERIC), NUMERIC_EQUALS(RawTransactionValueType.NUMERIC),

View file

@ -13,13 +13,11 @@ import java.util.Collection;
@Repository @Repository
public interface CategorizationRepository extends JpaRepository<Categorization, Long> { public interface CategorizationRepository extends JpaRepository<Categorization, Long> {
// We fetch only the ones with non-null category ids
// because ones with null category are used in AND, OR or NOT logical operations
@Query( @Query(
value = """ value = """
SELECT cat.* SELECT cat.*
FROM categories.categorization AS cat FROM categories.categorization AS cat
WHERE user_id = :userId AND category_id IS NOT NULL WHERE user_id = :userId
""", """,
nativeQuery = true nativeQuery = true
) )
@ -31,7 +29,7 @@ public interface CategorizationRepository extends JpaRepository<Categorization,
childCats AS ( childCats AS (
SELECT root.* SELECT root.*
FROM categories.categorization AS root FROM categories.categorization AS root
WHERE root.category_id = :categoryId WHERE root.category_id = :categoryId AND root.is_root = TRUE
UNION ALL UNION ALL

View file

@ -84,6 +84,7 @@ public class CategoryService {
// Run each category's rules for all transactions in parallel to eachother // Run each category's rules for all transactions in parallel to eachother
final var futures = categorizations.stream() final var futures = categorizations.stream()
.filter(Categorization::isRoot)
.collect(Collectors.groupingBy(Categorization::getCategoryId, HashMap::new, Collectors.toList())) .collect(Collectors.groupingBy(Categorization::getCategoryId, HashMap::new, Collectors.toList()))
.entrySet() .entrySet()
.stream() .stream()
@ -138,13 +139,14 @@ public class CategoryService {
private boolean matchesRule(final Collection<Categorization> allCategorizations, final Categorization categorization, final ProcessedTransaction processedTransaction) { private boolean matchesRule(final Collection<Categorization> allCategorizations, final Categorization categorization, final ProcessedTransaction processedTransaction) {
return switch (categorization.getCategorizationRule()) { return switch (categorization.getCategorizationRule()) {
// string operations // string operations
case STRING_REGEX, STRING_EQ, STRING_CONTAINS -> { case STRING_REGEX, STRING_EQ, STRING_CONTAINS, STRING_IS_EMPTY -> {
final String fieldValue = fetchTransactionStringValue(categorization, processedTransaction); final String fieldValue = fetchTransactionStringValue(categorization, processedTransaction);
yield switch (categorization.getCategorizationRule()) { yield switch (categorization.getCategorizationRule()) {
case STRING_EQ -> fieldValue.equalsIgnoreCase(categorization.getStringValue()); case STRING_EQ -> fieldValue.equalsIgnoreCase(categorization.getStringValue());
case STRING_REGEX -> fieldValue.matches(categorization.getStringValue()); case STRING_REGEX -> fieldValue.matches(categorization.getStringValue());
case STRING_CONTAINS -> fieldValue.contains(categorization.getStringValue()); case STRING_CONTAINS -> fieldValue.toLowerCase().contains(categorization.getStringValue().toLowerCase());
case STRING_IS_EMPTY -> fieldValue == null || fieldValue.isBlank();
default -> throw new CommonFinancesException("Unsupported string rule: %s", categorization.getCategorizationRule()); default -> throw new CommonFinancesException("Unsupported string rule: %s", categorization.getCategorizationRule());
}; };
} }
@ -271,7 +273,7 @@ public class CategoryService {
public Collection<CategorizationDTO> fetchCategorizationRules(Long categoryId) { public Collection<CategorizationDTO> fetchCategorizationRules(Long categoryId) {
final var categorizations = categorizationRepository.fetchForCategory(categoryId); final var categorizations = categorizationRepository.fetchForCategory(categoryId);
return categorizationRepository.fetchForCategory(categoryId).stream() return categorizationRepository.fetchForCategory(categoryId).stream()
.filter(c -> c.getCategoryId() != null) .filter(Categorization::isRoot)
.map(c -> mapCategorization(categorizations, c)) .map(c -> mapCategorization(categorizations, c))
.toList(); .toList();
} }
@ -312,16 +314,17 @@ public class CategoryService {
categorizationRepository.deleteAllForCategory(categoryId); categorizationRepository.deleteAllForCategory(categoryId);
return dtos.stream() return dtos.stream()
.map(dto -> saveCategorizationRule(categoryId, userId, dto).getId()) .map(dto -> saveCategorizationRule(true, categoryId, userId, dto).getId())
.toList(); .toList();
} }
private Categorization saveCategorizationRule(Long categoryId, Integer userId, CreateCategorizationDTO dto) { private Categorization saveCategorizationRule(boolean isRoot, Long categoryId, Integer userId, CreateCategorizationDTO dto) {
// TODO: Avoid recursion // TODO: Avoid recursion
final var categorization = new Categorization(); final var categorization = new Categorization();
categorization.setCategorizationRule(dto.rule()); categorization.setCategorizationRule(dto.rule());
categorization.setRoot(isRoot);
categorization.setUserId(userId); categorization.setUserId(userId);
categorization.setRuleBasedOn(dto.ruleBasedOn()); categorization.setRuleBasedOn(dto.ruleBasedOn());
categorization.setCategoryId(categoryId); categorization.setCategoryId(categoryId);
@ -331,17 +334,17 @@ public class CategoryService {
categorization.setNumericValue(dto.numericValue().orElse(null)); categorization.setNumericValue(dto.numericValue().orElse(null));
categorization.setTimestampGreaterThan(dto.timestampGreaterThan().orElse(null)); categorization.setTimestampGreaterThan(dto.timestampGreaterThan().orElse(null));
categorization.setTimestampLessThan(dto.timestampLessThan().orElse(null)); categorization.setTimestampLessThan(dto.timestampLessThan().orElse(null));
categorization.setBooleanValue(dto.booleanValue().orElse(null)); categorization.setBooleanValue(dto.booleanValue().orElse(false));
// Only root rules have category id set, to differentiate them from non-roots // Only root rules have category id set, to differentiate them from non-roots
// TODO: This smells bad. Add an isRoot property instead? // TODO: This smells bad. Add an isRoot property instead?
if (dto.left() != null) { if (dto.left() != null) {
final var leftCat = saveCategorizationRule(null, userId, dto.left()); final var leftCat = saveCategorizationRule(false, categoryId, userId, dto.left());
categorization.setLeftCategorizationId(leftCat.getId()); categorization.setLeftCategorizationId(leftCat.getId());
} }
if (dto.right() != null) { if (dto.right() != null) {
final var rightCat = saveCategorizationRule(null, userId, dto.right()); final var rightCat = saveCategorizationRule(false, categoryId, userId, dto.right());
categorization.setRightCategorizationId(rightCat.getId()); categorization.setRightCategorizationId(rightCat.getId());
} }

View file

@ -0,0 +1 @@
ALTER TABLE categories.categorization ADD COLUMN IF NOT EXISTS is_root BOOLEAN DEFAULT(FALSE);

View file

@ -80,6 +80,7 @@ export default function CategorizationRule({ ruleData, fields, ruleTypes, onDele
case "BOOLEAN_EQ": case "BOOLEAN_EQ":
case "NUMERIC_EQUALS": case "NUMERIC_EQUALS":
case "STRING_EQ": return "equals"; case "STRING_EQ": return "equals";
case "STRING_IS_EMPTY": return "is empty";
case "TIMESTAMP_GREATER_THAN": return "is later than"; case "TIMESTAMP_GREATER_THAN": return "is later than";
case "NUMERIC_GREATER_THAN": return "is greater than"; case "NUMERIC_GREATER_THAN": return "is greater than";
case "TIMESTAMP_LESS_THAN": return "is earlier than"; case "TIMESTAMP_LESS_THAN": return "is earlier than";
@ -220,6 +221,7 @@ export default function CategorizationRule({ ruleData, fields, ruleTypes, onDele
/> />
</Grid> </Grid>
); );
case "STRING_IS_EMPTY": return ("");
case "BOOLEAN_EQ": return ( case "BOOLEAN_EQ": return (
<Grid xs={1} lg={1}> <Grid xs={1} lg={1}>
<Checkbox <Checkbox