![JAR search and dependency download from the Maven repository](/logo.png)
io.github.perplexhub.rsql.jsonb.JsonbExpressionBuilder Maven / Gradle / Ivy
package io.github.perplexhub.rsql.jsonb;
import cz.jirutka.rsql.parser.ast.ComparisonOperator;
import java.util.*;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import static io.github.perplexhub.rsql.RSQLOperators.*;
import static io.github.perplexhub.rsql.jsonb.JsonbSupport.*;
/**
* Builds a jsonb expression for a given keyPath and operator.
*/
class JsonbExpressionBuilder {
/**
* The base json type.
*/
private enum BaseJsonType {
STRING, NUMBER, BOOLEAN, NULL, DATE_TIME, DATE_TIME_TZ
}
/**
* Interface for argument converters.
*/
interface ArgConverter {
boolean accepts(String s);
ArgValue convert(String s);
}
/**
* The argument value that holds the value and the base json type.
*/
record ArgValue(String value, BaseJsonType baseJsonType) {
String print(ComparisonOperator operator) {
return switch (baseJsonType) {
case STRING -> String.format("\"%s\"", printString(operator));
case NUMBER, BOOLEAN -> value;
case NULL -> "null";
case DATE_TIME, DATE_TIME_TZ -> String.format("\"%s\".datetime()", value);
};
}
String printString(ComparisonOperator operator) {
String value = this.value;
if ((operator.equals(LIKE)
|| operator.equals(NOT_LIKE)
|| operator.equals(IGNORE_CASE_LIKE)
|| operator.equals(IGNORE_CASE_NOT_LIKE))
&& !value.contains("*")
) {
return String.format(".*%s.*", value);
}
return value.replaceAll(WILD_CARD_PATTERN.pattern(), ".*");
}
}
private static final ArgConverter DATE_TIME_CONVERTER = new ArgConverter() {
@Override
public boolean accepts(String s) {
return ISO_DATE_TIME_PATTERN.matcher(s).matches()
|| ISO_DATE_PATTERN.matcher(s).matches()
|| ISO_TIME_PATTERN.matcher(s).matches();
}
@Override
public ArgValue convert(String s) {
return new ArgValue(s, BaseJsonType.DATE_TIME);
}
};
private static final ArgConverter DATE_TIME_CONVERTER_TZ = new ArgConverter() {
@Override
public boolean accepts(String s) {
return ISO_DATE_TIME_PATTERN_TZ.matcher(s).matches()
|| ISO_TIME_PATTERN_TZ.matcher(s).matches();
}
@Override
public ArgValue convert(String s) {
return new ArgValue(s, BaseJsonType.DATE_TIME_TZ);
}
};
private static final ArgConverter NUMBER_CONVERTER = new ArgConverter() {
@Override
public boolean accepts(String s) {
return NUMBER_PATTERN.matcher(s).matches()
|| INTEGER_PATTERN.matcher(s).matches();
}
@Override
public ArgValue convert(String s) {
return new ArgValue(s, BaseJsonType.NUMBER);
}
};
private static final ArgConverter BOOLEAN_ARG_CONVERTER = new ArgConverter() {
@Override
public boolean accepts(String s) {
return BOOLEAN_PATTERN.matcher(s).matches();
}
@Override
public ArgValue convert(String s) {
return new ArgValue(s, BaseJsonType.BOOLEAN);
}
};
private static final Pattern ISO_DATE_TIME_PATTERN_TZ = Pattern.compile("^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?(Z|[+-]\\d{2}:\\d{2})$");
private static final Pattern ISO_TIME_PATTERN_TZ = Pattern.compile("^\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?(Z|[+-]\\d{2}:\\d{2})$");
private static final Pattern ISO_DATE_TIME_PATTERN = Pattern.compile("^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?$");
private static final Pattern ISO_DATE_PATTERN = Pattern.compile("^\\d{4}-\\d{2}-\\d{2}$");
private static final Pattern ISO_TIME_PATTERN = Pattern.compile("^\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?$");
private static final Pattern BOOLEAN_PATTERN = Pattern.compile("^(true|false)$");
private static final Pattern NUMBER_PATTERN = Pattern.compile("^\\d+\\.\\d+$");
private static final Pattern INTEGER_PATTERN = Pattern.compile("^\\d+$");
private static final Pattern WILD_CARD_PATTERN = Pattern.compile("\\*");
private static final Set FORBIDDEN_NEGATION =
Set.of(NOT_EQUAL, IS_NULL, NOT_IN, NOT_LIKE, IGNORE_CASE_NOT_LIKE, NOT_BETWEEN);
private static final Set NOT_RELEVANT_FOR_CONVERSION =
Set.of(NOT_NULL, LIKE, IGNORE_CASE);
private static final Set REQUIRE_NO_ARGUMENTS =
Set.of(NOT_NULL);
private static final Set REQUIRE_ONE_ARGUMENT =
Set.of(EQUAL, GREATER_THAN, GREATER_THAN_OR_EQUAL, LESS_THAN, LESS_THAN_OR_EQUAL,
LIKE, IGNORE_CASE, IGNORE_CASE_LIKE);
private static final Set REQUIRE_TWO_ARGUMENTS = Set.of(BETWEEN);
private static final Set REQUIRE_AT_LEAST_ONE_ARGUMENT = Set.of(IN);
private static final Map COMPARISON_TEMPLATE = Map.ofEntries(
Map.entry(NOT_NULL, "(%s != null)"),
Map.entry(EQUAL, "(%s == %s)"),
Map.entry(GREATER_THAN, "(%s > %s)"),
Map.entry(GREATER_THAN_OR_EQUAL, "(%s >= %s)"),
Map.entry(LESS_THAN, "(%s < %s)"),
Map.entry(LESS_THAN_OR_EQUAL, "(%s <= %s)"),
Map.entry(LIKE, "(%s like_regex %s)"),
Map.entry(IGNORE_CASE, "(%s like_regex %s flag \"i\")"),
Map.entry(IGNORE_CASE_LIKE, "(%s like_regex %s flag \"i\")"),
Map.entry(BETWEEN, "(%1$s >= %2$s && %1$s <= %3$s)")
);
private static final String JSONB_PATH_EXISTS = "jsonb_path_exists";
private static final String JSONB_PATH_EXISTS_TZ = "jsonb_path_exists_tz";
private final ComparisonOperator operator;
private final String keyPath;
private final List values;
JsonbExpressionBuilder(ComparisonOperator operator, String keyPath, List args) {
this.operator = Objects.requireNonNull(operator);
this.keyPath = Objects.requireNonNull(keyPath);
if(FORBIDDEN_NEGATION.contains(operator)) {
throw new IllegalArgumentException("Operator " + operator + " cannot be negated");
}
var candidateValues = removeEmptyValuesIfNullCheck(operator, args);
if(candidateValues.isEmpty() && !REQUIRE_NO_ARGUMENTS.contains(operator)) {
throw new IllegalArgumentException("Values must not be empty");
}
if(REQUIRE_TWO_ARGUMENTS.contains(operator) && candidateValues.size() != 2) {
throw new IllegalArgumentException("Operator " + operator + " requires two values");
}
if(REQUIRE_ONE_ARGUMENT.contains(operator) && candidateValues.size() != 1) {
throw new IllegalArgumentException("Operator " + operator + " requires one value");
}
if(REQUIRE_AT_LEAST_ONE_ARGUMENT.contains(operator) && candidateValues.isEmpty()) {
throw new IllegalArgumentException("Operator " + operator + " requires at least one value");
}
this.values = findMoreTypes(operator, candidateValues);
}
/**
* Builds a json jsonbPath expression for a given keyPath and operator.
* @return the json jsonbPath expression
*/
public JsonbPathExpression getJsonPathExpression() {
List valuesToCompare = values.stream().map(argValue -> argValue.print(operator)).toList();
String targetPath = String.format("$.%s", removeJsonbReferenceFromKeyPath(keyPath));
boolean isDataTime = values.stream().anyMatch(argValue -> argValue.baseJsonType().equals(BaseJsonType.DATE_TIME));
boolean isDateTimeTz = values.stream().anyMatch(argValue -> argValue.baseJsonType().equals(BaseJsonType.DATE_TIME_TZ));
String valueReference = values.stream()
.filter(v -> isDataTime || isDateTimeTz)
.findFirst()
.map(v -> "@.datetime()")
.orElse("@");
ComparisonOperator realOperator = transformEqualsToLike(operator, valuesToCompare);
String comparisonTemplate = operatorToTemplate(realOperator, valuesToCompare.size());
List templateArguments = new ArrayList<>();
templateArguments.add(valueReference);
templateArguments.addAll(valuesToCompare);
var function = isDateTimeTz ? JSONB_PATH_EXISTS_TZ : JSONB_PATH_EXISTS;
var expression = String.format("%s ? %s", targetPath, String.format(comparisonTemplate, templateArguments.toArray()));
return new JsonbPathExpression(function, expression);
}
/**
* If the operator is NOT_NULL, we will remove all values.
*/
private List removeEmptyValuesIfNullCheck(ComparisonOperator operator, List args) {
if(operator.equals(NOT_NULL)) {
return Collections.emptyList();
}
return args;
}
/**
* Try to find a more specific type for the given values.
* We will keep the original value if we cannot find a more specific type for all values.
*/
private List findMoreTypes(ComparisonOperator operator, List values) {
if(NOT_RELEVANT_FOR_CONVERSION.contains(operator)) {
return values.stream().map(s -> new ArgValue(s, BaseJsonType.STRING)).toList();
}
List argConverters = DATE_TIME_SUPPORT ?
List.of(DATE_TIME_CONVERTER, DATE_TIME_CONVERTER_TZ, NUMBER_CONVERTER, BOOLEAN_ARG_CONVERTER)
: List.of(NUMBER_CONVERTER, BOOLEAN_ARG_CONVERTER);
Optional candidateConverter = argConverters.stream()
.filter(argConverter -> values.stream().allMatch(argConverter::accepts))
.findFirst();
return candidateConverter.map(argConverter -> values.stream()
.map(argConverter::convert).toList())
.orElseGet(() -> values.stream().map(s -> new ArgValue(s, BaseJsonType.STRING)).toList());
}
/**
* If the operator is EQUAL and one of the values contains a wildcard, we will transform the operator to LIKE.
*/
private ComparisonOperator transformEqualsToLike(ComparisonOperator operator, List valuesToCompare) {
boolean hasWildcard = valuesToCompare.stream().anyMatch(s -> s.contains("*"));
if(!hasWildcard) {
return operator;
}
if(operator.equals(EQUAL)) {
return LIKE;
}
return operator;
}
/**
* Removes the jsonb reference from the keyPath.
* @param keyPath the keyPath
* @return the keyPath without the jsonb reference
*/
String removeJsonbReferenceFromKeyPath(String keyPath) {
List keyPathParts = Arrays.asList(keyPath.split("\\."));
if(keyPathParts.isEmpty()) {
return "";
}
//Forget the first part as it represents the jsonb column name
keyPathParts = keyPathParts.subList(1, keyPathParts.size());
return String.join(".", keyPathParts);
}
/**
* Returns the String template for the given operator.
* @param operator the operator
* @param numberOfArguments the number of arguments
* @return the template
*/
String operatorToTemplate(ComparisonOperator operator, int numberOfArguments) {
if (operator.equals(IN)) {
if(numberOfArguments < 1) {
throw new IllegalArgumentException("In operator requires at least one value");
}
var orChain = new ArrayList();
for (int i = 1; i <= numberOfArguments; i++) {
orChain.add("%1$s == %" + (i + 1) + "$s");
}
return orChain.stream()
.collect(Collectors.joining(" || ", "(", ")"));
}
return Optional.ofNullable(COMPARISON_TEMPLATE.get(operator))
.orElseThrow(() -> new UnsupportedOperationException(operator + " is not supported yet"));
}
}
© 2015 - 2025 Weber Informatics LLC | Privacy Policy