From 7efc4e7649c94ca84ccbc2546772825fa3a04ae5 Mon Sep 17 00:00:00 2001 From: Valeriy Vyrva Date: Tue, 1 Aug 2023 13:21:25 +0300 Subject: [PATCH] TMF: Filter extractor --- .../tmf/FilterExtractorJBenchmark.java | 66 ++ .../problem/tmf/FilterExtractorJ.java | 694 ++++++++++++++++++ .../problem/tmf/FilterExtractorJTest.kt | 213 ++++++ 3 files changed, 973 insertions(+) create mode 100644 src/jmh/java/name/valery1707/problem/tmf/FilterExtractorJBenchmark.java create mode 100644 src/main/java/name/valery1707/problem/tmf/FilterExtractorJ.java create mode 100644 src/test/kotlin/name/valery1707/problem/tmf/FilterExtractorJTest.kt diff --git a/src/jmh/java/name/valery1707/problem/tmf/FilterExtractorJBenchmark.java b/src/jmh/java/name/valery1707/problem/tmf/FilterExtractorJBenchmark.java new file mode 100644 index 0000000..fbb19c3 --- /dev/null +++ b/src/jmh/java/name/valery1707/problem/tmf/FilterExtractorJBenchmark.java @@ -0,0 +1,66 @@ +package name.valery1707.problem.tmf; + +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.Param; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.Warmup; +import org.openjdk.jmh.infra.Blackhole; + +/** + *

+ * (query)                (variant)   Mode  Cnt     Score      Error  Units
+ *  MEDIUM           naive  thrpt   10  5552,422 ± 7287,394  ops/s
+ *  MEDIUM    maps_builder  thrpt   10  2227,318 ±  436,243  ops/s
+ *  MEDIUM  switch_builder  thrpt   10  7867,319 ± 3186,571  ops/s
+ *  MEDIUM   switch_offset  thrpt   10  9998,258 ± 3270,732  ops/s
+ *   LARGE           naive  thrpt   10   840,528 ±  321,186  ops/s
+ *   LARGE    maps_builder  thrpt   10   968,454 ±  287,701  ops/s
+ *   LARGE  switch_builder  thrpt   10  2415,841 ±  687,946  ops/s
+ *   LARGE   switch_offset  thrpt   10  2746,451 ±  511,587  ops/s
+ * 
+ */ +@State(Scope.Benchmark) +@Warmup(iterations = 2, time = 2) +@Measurement(iterations = 5, time = 5) +@Fork(2) +@BenchmarkMode({Mode.Throughput}) +@SuppressWarnings("unused") +public class FilterExtractorJBenchmark { + + public enum Query { + MEDIUM("@type=GSMCTN&value%3E%3D&value%3C%3D" + + "&resourceCharacteristic[@type%3DNGP].value.ngp=" + + "&resourceCharacteristic[@type%3DSTATUS].value.ctn_status='"), + LARGE("operatorId=one-person,two-person" + + "&createdAt%3E2013-04-20&createdAt%3C2017-04-20" + + "&updatedAt.lte=2013-04-20;updatedAt.gte=2017-04-20" + + "&msisdn.regex=A;msisdn%3d~B;msisdn%3D~C" + + "&a.gt=1&b.gte=2&c.lt=3&d.lte=4&e.regex=5" + + "&f.GT=1&g.GTE=2&h.LT=3&i.LTE=4&j.REGEX=5" + + "&k%3E1&l%3E%3D2&m%3C3&l%3C%3D4&o%3D~5&p%3D%3D6" + + "&rootAttr.nestedAttr=value" + + "&resourceCharacteristic[%40type%3DIDS].operator_id=OpOp" + + "&order=1"); + + private final String query; + + Query(String query) {this.query = query;} + } + + @Param + private Query query; + + @Param + private FilterExtractorJ.Implementation variant; + + @Benchmark + public void benchmark(Blackhole bh) { + bh.consume(variant.parseQuery(query.query)); + } + +} diff --git a/src/main/java/name/valery1707/problem/tmf/FilterExtractorJ.java b/src/main/java/name/valery1707/problem/tmf/FilterExtractorJ.java new file mode 100644 index 0000000..0b9f130 --- /dev/null +++ b/src/main/java/name/valery1707/problem/tmf/FilterExtractorJ.java @@ -0,0 +1,694 @@ +package name.valery1707.problem.tmf; + +import java.net.URLDecoder; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.TreeMap; +import java.util.stream.Stream; + +import static java.lang.Character.isLowerCase; +import static java.lang.Character.toUpperCase; +import static java.lang.String.CASE_INSENSITIVE_ORDER; +import static java.nio.charset.StandardCharsets.UTF_8; +import static java.util.Comparator.comparingInt; +import static java.util.function.Function.identity; +import static java.util.stream.Collectors.collectingAndThen; +import static java.util.stream.Collectors.toMap; + +/** + *

4.4 Query Resources with attribute filtering

+ *

+ * The following section describes how to retrieve resources using an attribute filtering mechanism. + * The filtering is based on using name value query parameters on entity attributes. + *

+ * The basic expression is a sequence of attribute assertions being ANDed to formulate a filtering expression: + *


+ * GET \{apiRoot}/\{resourceName}?[\{attributeName}=\{attributeValue}&*]
+ * 
+ * For examples: + *

+ * GET /api/troubleTicket/?status=acknowledged&creationDate=2017-04-20
+ * 
+ * Note that the above expressions match only for attribute value equality. + *

+ * Attribute values OR-ing is supported and is achieved by providing a filtering expression where the same + * attribute name is duplicated a number of times {@code [{attributeName}={attributeValue}&*]} different values. + *

+ * Alternatively the following expression {@code [{attributeName}={attributeValue},{attributeValue}*]} is also supported. + * OR-ing can also be explicit by using “;“ many time {@code [{attributeName}={attributeValue};{attributeValue}*]}. + *

+ * The “OR” behavior can also be achieved by providing a filtering expression using one of the following expressions. + *

+ * For example: + *

    + *
  • {@code GET /api/troubleTicket?status=acknowledged;status=rejected}
  • + *
  • {@code GET /api/troubleTicket?status=acknowledged,rejected}
  • + *
+ * The following operators may be used: + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + *
Operator literalDescriptionURL Encoded form
.gt >greater than (>)
Return results where the search criteria field is strictly greater than
%3E
.gte >=greater than or equal to (>=)
Return results where the search criteria field is equal of greater than
%3E%3D
.lt <less than (<)
Return results where the search criteria field is strictly less than
%3C
.lte <=less than or equal to (<=)
Return results where the search criteria field is equal or less than
%3C%3D
.regex *=Regexp expression
Note: all regexp special characters must be encoded
%3D~
.eqequal to (=)
Returns results where the search criteria fields is equal to
%3D%3D
+ * Examples: + *

+ * Using AND-ed to describe {@code "2017-04-20>dateTime>2013-04-20"}: + *


+ * GET /api/troubleTicket?dateTime%3E2013-04-20&dateTime%3C2017-04-20
+ * 
+ * Using OR-ing to describe {@code "dateTime<2013-04-20 OR dateTime>2017-04-20"}: + *

+ * GET /api/troubleTicket?dateTime%3C2013-04-20;dateTime%3E2017-04-20
+ * 
+ *

+ * Complex attribute value type may be filtered using a “.” notation. + *


+ * {empty}[\{attributeName.attributeName}=\{attributeValue}&*]
+ * 
+ * The complete resource representations (with all the attributes) of all the matching entities must be returned. + *

+ * The returned representation of each entity must contain a field called «id» and that field be populated with the resourceID. + *

+ * If the request is successful then the returned code {@code MUST} be {@code 200 OK}. + *

+ * The exceptions code must use the exception codes from http-status-codes + * as explained in section 4.3. + *

+ * Example: + *

+ * Retrieve all Trouble Tickets with dateTime greater than {@code 2013-04-20} and status {@code acknowledged}. + *

    + *
  • {@code GET /api/troubleTicket?dateTime.gt=2013-04-20&status=acknowledged}
  • + *
  • {@code GET /api/troubleTicket?dateTime%3E2013-04-20&status=acknowledged}
  • + *
+ * + * @see TMF630 REST API Design Guidelines 4.2.0 + */ +public interface FilterExtractorJ { + + List parseQuery(String query); + + default String urlDecode(String encoded) { + return URLDecoder.decode(encoded, UTF_8); + } + + @SuppressWarnings("DuplicatedCode") + enum Implementation implements FilterExtractorJ { + naive { + @Override + public List parseQuery(String query) { + List filters = new ArrayList<>(); + var step = ParsingStep.FIELD; + var mode = Mode.AND; + var operation = Operation.EQ; + var field = new StringBuilder(); + var value = new StringBuilder(); + for (int i = 0, length = query.length(); i < length; i++) { + var curr = query.charAt(i); + if (curr == '&' || curr == ';') { + //Start next item + //todo Valid only in VALUE step + filters.add(new Filter(mode, urlDecode(field.toString()), operation, urlDecode(value.toString()))); + mode = curr == '&' ? Mode.AND : Mode.OR; + field.delete(0, field.length()); + value.delete(0, value.length()); + step = ParsingStep.FIELD; + continue; + } + if (curr == ',') { + //Start next OR variant (preserve all except value and mode) + //todo Valid only in VALUE step + filters.add(new Filter(mode, urlDecode(field.toString()), operation, urlDecode(value.toString()))); + mode = Mode.OR; + value.delete(0, value.length()); + continue; + } + if (curr == '=') { + //Start value + //todo Valid only in FIELD step + step = ParsingStep.VALUE; + operation = Operation.EQ; + //Extract operation from field suffix + var lastDot = field.lastIndexOf("."); + if (lastDot > 0) { + var suffix = field.substring(lastDot); + if (suffix.equalsIgnoreCase(".eq")) { + operation = Operation.EQ; + field.delete(lastDot, field.length()); + } else if (suffix.equalsIgnoreCase(".gt")) { + operation = Operation.GT; + field.delete(lastDot, field.length()); + } else if (suffix.equalsIgnoreCase(".gte")) { + operation = Operation.GTE; + field.delete(lastDot, field.length()); + } else if (suffix.equalsIgnoreCase(".lt")) { + operation = Operation.LT; + field.delete(lastDot, field.length()); + } else if (suffix.equalsIgnoreCase(".lte")) { + operation = Operation.LTE; + field.delete(lastDot, field.length()); + } else if (suffix.equalsIgnoreCase(".regex")) { + operation = Operation.REGEX; + field.delete(lastDot, field.length()); + } + } + continue; + } + if (length - i >= 6) { + var next = query.substring(i, i + 6); + if (next.equalsIgnoreCase("%3E%3D")) { + //todo Valid only in FIELD step + step = ParsingStep.VALUE; + operation = Operation.GTE; + i += 5; + continue; + } else if (next.equalsIgnoreCase("%3C%3D")) { + //todo Valid only in FIELD step + step = ParsingStep.VALUE; + operation = Operation.LTE; + i += 5; + continue; + } else if (next.equalsIgnoreCase("%3D%3D")) { + //todo Valid only in FIELD step + step = ParsingStep.VALUE; + operation = Operation.EQ; + i += 5; + continue; + } + } + if (length - i >= 4) { + var next = query.substring(i, i + 4); + if (next.equalsIgnoreCase("%3D~")) { + //todo Valid only in FIELD step + step = ParsingStep.VALUE; + operation = Operation.REGEX; + i += 3; + continue; + } + } + if (length - i >= 3) { + var next = query.substring(i, i + 3); + if (next.equalsIgnoreCase("%3E")) { + //todo Valid only in FIELD step + step = ParsingStep.VALUE; + operation = Operation.GT; + i += 2; + continue; + } else if (next.equalsIgnoreCase("%3C")) { + //todo Valid only in FIELD step + step = ParsingStep.VALUE; + operation = Operation.LT; + i += 2; + continue; + } + } + (switch (step) { + case FIELD -> field; + case VALUE -> value; + }).append(curr); + } + if (!value.isEmpty()) { + //Finish last item + filters.add(new Filter(mode, urlDecode(field.toString()), operation, urlDecode(value.toString()))); + } + return filters.stream().map(Filter::toString).toList(); + } + + private record Filter(Mode mode, String field, Operation operation, String value) { + + @Override + public String toString() { + return "Filter(%s [%s] %s [%s])".formatted(mode, field, operation, value); + } + + } + private enum Mode { + AND, OR + } + private enum Operation { + GT, GTE, + LT, LTE, + REGEX, EQ + } + private enum ParsingStep { + FIELD, VALUE + } + }, + maps_builder { + @Override + public List parseQuery(String query) { + List filters = new ArrayList<>(); + ParsingStep step = ParsingStep.FIELD; + Mode mode = Mode.AND; + Operation operation = Operation.EQ; + var field = new StringBuilder(); + var value = new StringBuilder(); + for (int i = 0, length = query.length(); i < length; i++) { + var curr = query.charAt(i); + + if (step == ParsingStep.FIELD) { + Operation op; + if (curr == '=') { + //Start value + step = ParsingStep.VALUE; + operation = Operation.EQ; + if (null != (op = Operation.bySuffix(field))) { + operation = op; + field.delete(field.length() - op.suffix.length(), field.length()); + } + } else if (null != (op = Operation.byEncoded(query, i))) { + step = ParsingStep.VALUE; + operation = op; + i += op.encoded.length() - 1; + } else { + field.append(curr); + } + } else /*step == ParsingStep.VALUE*/ { + Mode m; + if (null != (m = Mode.found(curr))) { + filters.add(new Filter(mode, urlDecode(field.toString()), operation, urlDecode(value.toString()))); + mode = m; + field.delete(0, field.length()); + value.delete(0, value.length()); + step = ParsingStep.FIELD; + } else if (curr == ',') { + //Start next OR variant (preserve all except value and mode) + filters.add(new Filter(mode, urlDecode(field.toString()), operation, urlDecode(value.toString()))); + mode = Mode.OR; + value.delete(0, value.length()); + } else { + value.append(curr); + } + } + } + if (!value.isEmpty()) { + //Finish last item + filters.add(new Filter(mode, urlDecode(field.toString()), operation, urlDecode(value.toString()))); + } + return filters.stream().map(Filter::toString).toList(); + } + + private record Filter(Mode mode, String field, Operation operation, String value) { + + @Override + public String toString() { + return "Filter(%s [%s] %s [%s])".formatted(mode, field, operation, value); + } + + } + + private enum Mode { + AND, OR; + + static Mode found(char curr) { + return switch (curr) { + case '&' -> AND; + case ';' -> OR; + default -> null; + }; + } + } + private enum Operation { + GT(".gt", "%3E"), GTE(".gte", "%3E%3D"), + LT(".lt", "%3C"), LTE(".lte", "%3C%3D"), + REGEX(".regex", "%3D~"), EQ(".eq", "%3D%3D"); + + private static final Map BY_SUFFIX = Stream.of(values()).collect(collectingAndThen( + toMap(it -> it.suffix, identity(), Objects::requireNonNullElse, () -> new TreeMap<>(CASE_INSENSITIVE_ORDER)), + Collections::unmodifiableMap + )); + private static final Map BY_ENCODED = Stream.of(values()).collect(collectingAndThen( + toMap(it -> it.encoded, identity(), Objects::requireNonNullElse, () -> new TreeMap<>( + comparingInt(String::length).reversed().thenComparing(String::compareTo) + )), + Collections::unmodifiableMap + )); + + private final String suffix; + private final String encoded; + + Operation(String suffix, String encoded) { + this.suffix = suffix; + this.encoded = encoded; + } + + public static Operation bySuffix(StringBuilder field) { + var lastDot = field.lastIndexOf("."); + return lastDot > 0 ? BY_SUFFIX.get(field.substring(lastDot)) : null; + } + + public static Operation byEncoded(String query, int i) { + return BY_ENCODED + .entrySet().stream() + .filter(it -> startWith(query, i, it.getKey())) + .findFirst() + .map(Map.Entry::getValue) + .orElse(null); + } + + private static boolean startWith(String base, int i, String search) { + if (i + search.length() >= base.length()) { + return false; + } + for (int s = 0, len = search.length(); s < len; s++) { + char lhs = base.charAt(i + s); + char rhs = search.charAt(s); + if (lhs != rhs) { + if (isLowerCase(lhs) != isLowerCase(rhs)) { + if (toUpperCase(lhs) != toUpperCase(rhs)) { + return false; + } + } else { + return false; + } + } + } + return true; + } + } + private enum ParsingStep { + FIELD, VALUE + } + }, + switch_builder { + @Override + public List parseQuery(String query) { + List filters = new ArrayList<>(); + ParsingStep step = ParsingStep.FIELD; + Mode mode = Mode.AND; + Operation operation = Operation.EQ; + var field = new StringBuilder(); + var value = new StringBuilder(); + for (int i = 0, length = query.length(); i < length; i++) { + var curr = query.charAt(i); + + if (step == ParsingStep.FIELD) { + Operation op; + if (curr == '=') { + //Start value + step = ParsingStep.VALUE; + operation = Operation.EQ; + if (null != (op = Operation.bySuffix(field))) { + operation = op; + field.delete(field.length() - op.suffixLen, field.length()); + } + } else if (null != (op = Operation.byEncoded(query, i))) { + step = ParsingStep.VALUE; + operation = op; + i += op.encodedLen - 1; + } else { + field.append(curr); + } + } else /*step == ParsingStep.VALUE*/ { + Mode m; + if (null != (m = Mode.found(curr))) { + filters.add(new Filter(mode, urlDecode(field.toString()), operation, urlDecode(value.toString()))); + mode = m; + field.delete(0, field.length()); + value.delete(0, value.length()); + step = ParsingStep.FIELD; + } else if (curr == ',') { + //Start next OR variant (preserve all except value and mode) + filters.add(new Filter(mode, urlDecode(field.toString()), operation, urlDecode(value.toString()))); + mode = Mode.OR; + value.delete(0, value.length()); + } else { + value.append(curr); + } + } + } + if (!value.isEmpty()) { + //Finish last item + filters.add(new Filter(mode, urlDecode(field.toString()), operation, urlDecode(value.toString()))); + } + return filters.stream().map(Filter::toString).toList(); + } + + private record Filter(Mode mode, String field, Operation operation, String value) { + + @Override + public String toString() { + return "Filter(%s [%s] %s [%s])".formatted(mode, field, operation, value); + } + + } + + private enum Mode { + AND, OR; + + static Mode found(char curr) { + return switch (curr) { + case '&' -> AND; + case ';' -> OR; + default -> null; + }; + } + } + private enum Operation { + GT(".gt", "%3E"), GTE(".gte", "%3E%3D"), + LT(".lt", "%3C"), LTE(".lte", "%3C%3D"), + REGEX(".regex", "%3D~"), EQ(".eq", "%3D%3D"); + + private final int suffixLen; + private final int encodedLen; + + Operation(String suffix, String encoded) { + this.suffixLen = suffix.length(); + this.encodedLen = encoded.length(); + } + + public static Operation bySuffix(StringBuilder field) { + var lastDot = field.lastIndexOf("."); + return lastDot <= 0 ? null : switch (field.substring(lastDot)) { + case ".eq", ".eQ", ".Eq", ".EQ" -> EQ; + case ".gt", ".gT", ".Gt", ".GT" -> GT; + case ".lt", ".lT", ".Lt", ".LT" -> LT; + case ".gte", ".gtE", ".gTe", ".gTE", ".Gte", ".GtE", ".GTe", ".GTE" -> GTE; + case ".lte", ".ltE", ".lTe", ".lTE", ".Lte", ".LtE", ".LTe", ".LTE" -> LTE; + case ".regex" + , ".regeX", ".regEx", ".regEX", ".reGex", ".reGeX" + , ".reGEx", ".reGEX", ".rEgex", ".rEgeX", ".rEgEx" + , ".rEgEX", ".rEGex", ".rEGeX", ".rEGEx", ".rEGEX" + , ".Regex", ".RegeX", ".RegEx", ".RegEX", ".ReGex" + , ".ReGeX", ".ReGEx", ".ReGEX", ".REgex", ".REgeX" + , ".REgEx", ".REgEX", ".REGex", ".REGeX", ".REGEx" + , ".REGEX" -> REGEX; + default -> null; + }; + } + + public static Operation byEncoded(String s, int i) { + int accessible = s.length() - i; + if (s.charAt(i) != '%' || accessible <= 2 || s.charAt(i + 1) != '3') { + return null; + } + if (accessible >= 6 && s.charAt(i + 3) == '%' && s.charAt(i + 4) == '3' && (s.charAt(i + 5) == 'd' || s.charAt(i + 5) == 'D')) { + if (s.charAt(i + 2) == 'e' || s.charAt(i + 2) == 'E') { + return GTE; + } else if (s.charAt(i + 2) == 'c' || s.charAt(i + 2) == 'C') { + return LTE; + } else if (s.charAt(i + 2) == 'd' || s.charAt(i + 2) == 'D') { + return EQ; + } + } + if (accessible >= 4 && s.charAt(i + 3) == '~' && (s.charAt(i + 2) == 'd' || s.charAt(i + 2) == 'D')) { + return REGEX; + } + //accessible >= 3 + if (s.charAt(i + 2) == 'e' || s.charAt(i + 2) == 'E') { + return GT; + } else if (s.charAt(i + 2) == 'c' || s.charAt(i + 2) == 'C') { + return LT; + } + return null; + } + } + private enum ParsingStep { + FIELD, VALUE + } + }, + switch_offset { + @Override + public List parseQuery(String query) { + List filters = new ArrayList<>(); + ParsingStep step = ParsingStep.FIELD; + Mode mode = Mode.AND; + Operation operation = Operation.EQ; + int fieldS = 0, fieldE = 0; + int valueS = 0, valueE = 0; + for (int i = 0, length = query.length(); i < length; i++) { + var curr = query.charAt(i); + + if (step == ParsingStep.FIELD) { + Operation op; + if (curr == '=') { + step = ParsingStep.VALUE; + valueS = valueE = i + 1; + operation = Operation.EQ; + if (null != (op = Operation.bySuffix(query.substring(fieldS, fieldE)))) { + operation = op; + fieldE -= op.suffixLen; + } + } else if (null != (op = Operation.byEncoded(query, i))) { + step = ParsingStep.VALUE; + valueS = valueE = i + op.encodedLen; + operation = op; + i += op.encodedLen - 1; + } else { + fieldE++; + } + } else /*step == ParsingStep.VALUE*/ { + Mode m; + if (null != (m = Mode.found(curr))) { + filters.add(new Filter(mode, urlDecode(query.substring(fieldS, fieldE)), operation, urlDecode(query.substring(valueS, valueE)))); + mode = m; + fieldS = fieldE = i + 1; + valueS = valueE = i + 1; + step = ParsingStep.FIELD; + } else if (curr == ',') { + //Start next OR variant (preserve all except value and mode) + filters.add(new Filter(mode, urlDecode(query.substring(fieldS, fieldE)), operation, urlDecode(query.substring(valueS, valueE)))); + mode = Mode.OR; + valueS = valueE = i + 1; + } else { + valueE++; + } + } + } + if (valueE > valueS) { + //Finish last item + filters.add(new Filter(mode, urlDecode(query.substring(fieldS, fieldE)), operation, urlDecode(query.substring(valueS, valueE)))); + } + return filters.stream().map(Filter::toString).toList(); + } + + private record Filter(Mode mode, String field, Operation operation, String value) { + + @Override + public String toString() { + return "Filter(%s [%s] %s [%s])".formatted(mode, field, operation, value); + } + + } + + private enum Mode { + AND, OR; + + static Mode found(char curr) { + return switch (curr) { + case '&' -> AND; + case ';' -> OR; + default -> null; + }; + } + } + private enum Operation { + GT(".gt", "%3E"), GTE(".gte", "%3E%3D"), + LT(".lt", "%3C"), LTE(".lte", "%3C%3D"), + REGEX(".regex", "%3D~"), EQ(".eq", "%3D%3D"); + + private final int suffixLen; + private final int encodedLen; + + Operation(String suffix, String encoded) { + this.suffixLen = suffix.length(); + this.encodedLen = encoded.length(); + } + + public static Operation bySuffix(String field) { + var lastDot = field.lastIndexOf("."); + return lastDot <= 0 ? null : switch (field.substring(lastDot)) { + case ".eq", ".eQ", ".Eq", ".EQ" -> EQ; + case ".gt", ".gT", ".Gt", ".GT" -> GT; + case ".lt", ".lT", ".Lt", ".LT" -> LT; + case ".gte", ".gtE", ".gTe", ".gTE", ".Gte", ".GtE", ".GTe", ".GTE" -> GTE; + case ".lte", ".ltE", ".lTe", ".lTE", ".Lte", ".LtE", ".LTe", ".LTE" -> LTE; + case ".regex" + , ".regeX", ".regEx", ".regEX", ".reGex", ".reGeX" + , ".reGEx", ".reGEX", ".rEgex", ".rEgeX", ".rEgEx" + , ".rEgEX", ".rEGex", ".rEGeX", ".rEGEx", ".rEGEX" + , ".Regex", ".RegeX", ".RegEx", ".RegEX", ".ReGex" + , ".ReGeX", ".ReGEx", ".ReGEX", ".REgex", ".REgeX" + , ".REgEx", ".REgEX", ".REGex", ".REGeX", ".REGEx" + , ".REGEX" -> REGEX; + default -> null; + }; + } + + public static Operation byEncoded(String s, int i) { + int accessible = s.length() - i; + if (s.charAt(i) != '%' || accessible <= 2 || s.charAt(i + 1) != '3') { + return null; + } + char ch2 = switch (s.charAt(i + 2)) { + case 'e', 'E' -> 'E'; + case 'c', 'C' -> 'C'; + case 'd', 'D' -> 'D'; + default -> 'X'; + }; + if (ch2 == 'X') { + return null; + } + if (accessible >= 6 && s.charAt(i + 3) == '%' && s.charAt(i + 4) == '3' && (s.charAt(i + 5) == 'd' || s.charAt(i + 5) == 'D')) { + if (ch2 == 'E') { + return GTE; + } else if (ch2 == 'C') { + return LTE; + } else /*if (ch2 == 'D')*/ { + return EQ; + } + } + if (accessible >= 4 && ch2 == 'D' && s.charAt(i + 3) == '~') { + return REGEX; + } + //accessible >= 3 + if (ch2 == 'E') { + return GT; + } else if (ch2 == 'C') { + return LT; + } + return null; + } + } + private enum ParsingStep { + FIELD, VALUE + } + }, + } + +} diff --git a/src/test/kotlin/name/valery1707/problem/tmf/FilterExtractorJTest.kt b/src/test/kotlin/name/valery1707/problem/tmf/FilterExtractorJTest.kt new file mode 100644 index 0000000..ea96cc3 --- /dev/null +++ b/src/test/kotlin/name/valery1707/problem/tmf/FilterExtractorJTest.kt @@ -0,0 +1,213 @@ +package name.valery1707.problem.tmf + +import name.valery1707.problem.junit.Implementation +import name.valery1707.problem.junit.ImplementationSource +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.CsvSource +import org.junit.jupiter.params.provider.MethodSource + +internal class FilterExtractorJTest { + + @ParameterizedTest(name = "[{index}] {0}({1})") + @ImplementationSource( + implementation = [FilterExtractorJ.Implementation::class], + csv = [ + CsvSource( + "''" + + ",'[]'", + + "'status=acknowledged&creationDate=2017-04-20'" + + ",'[" + + "Filter(AND [status] EQ [acknowledged]), " + + "Filter(AND [creationDate] EQ [2017-04-20])" + + "]'", + + "'status=acknowledged;status=rejected'" + + ",'[" + + "Filter(AND [status] EQ [acknowledged]), " + + "Filter(OR [status] EQ [rejected])" + + "]'", + + "'status=acknowledged,rejected'" + + ",'[" + + "Filter(AND [status] EQ [acknowledged]), " + + "Filter(OR [status] EQ [rejected])" + + "]'", + + "'dateTime%3E2013-04-20&dateTime%3C2017-04-20'" + + ",'[" + + "Filter(AND [dateTime] GT [2013-04-20]), " + + "Filter(AND [dateTime] LT [2017-04-20])" + + "]'", + + "'dateTime%3C2013-04-20;dateTime%3E2017-04-20'" + + ",'[" + + "Filter(AND [dateTime] LT [2013-04-20]), " + + "Filter(OR [dateTime] GT [2017-04-20])" + + "]'", + + "'dateTime.gt=2013-04-20&status=acknowledged'" + + ",'[" + + "Filter(AND [dateTime] GT [2013-04-20]), " + + "Filter(AND [status] EQ [acknowledged])" + + "]'", + + "'dateTime%3E2013-04-20&status=acknowledged'" + + ",'[" + + "Filter(AND [dateTime] GT [2013-04-20]), " + + "Filter(AND [status] EQ [acknowledged])" + + "]'", + + "'order%3E1'" + + ",'[" + + "Filter(AND [order] GT [1])" + + "]'", + + //Field contains start of "encoded" comparator `%3`, but has premature EOL + "'order%3'" + + ",'[" + + "]'", + + "'order%3E'" + + ",'[" + + "]'", + + "'value%3C%2B700'" + + ",'[" + + "Filter(AND [value] LT [+700])" + + "]'", + + "'order%30=1'" + + ",'[" + + "Filter(AND [order0] EQ [1])" + + "]'", + + "'order%30%3d%3d1'" + + ",'[" + + "Filter(AND [order0] EQ [1])" + + "]'", + "'order%30%3D%3D1'" + + ",'[" + + "Filter(AND [order0] EQ [1])" + + "]'", + + "'order%3e%31%37'" + + ",'[" + + "Filter(AND [order] GT [17])" + + "]'", + + "'order%3e%3d%32;order%3c%3d%38;order%3d%3d%35;order%30%3c%36'" + + ",'[" + + "Filter(AND [order] GTE [2]), " + + "Filter(OR [order] LTE [8]), " + + "Filter(OR [order] EQ [5]), " + + "Filter(OR [order0] LT [6])" + + "]'", + + "'ORDER%3E%3D%32;ORDER%3C%3D%38;ORDER%3D%3D%35;ORDER%30%3C%36'" + + ",'[" + + "Filter(AND [ORDER] GTE [2]), " + + "Filter(OR [ORDER] LTE [8]), " + + "Filter(OR [ORDER] EQ [5]), " + + "Filter(OR [ORDER0] LT [6])" + + "]'", + + "'.dot=1'" + + ",'[" + + "Filter(AND [.dot] EQ [1])" + + "]'", + + "'@type=GSMCTN&value%3E%3D&value%3C%3D&resourceCharacteristic[@type%3DNGP].value.ngp=&resourceCharacteristic[@type%3DSTATUS].value.ctn_status='" + + ",'[" + + "Filter(AND [@type] EQ [GSMCTN]), " + + "Filter(AND [value] GTE []), " + + "Filter(AND [value] LTE []), " + + "Filter(AND [resourceCharacteristic[@type=NGP].value.ngp] EQ []), " + + "Filter(AND [resourceCharacteristic[@type=STATUS].value.ctn_status] EQ [])" + + "]'", + + "'operatorId=one-person,two-person" + + "&createdAt%3E2013-04-20&createdAt%3C2017-04-20" + + "&updatedAt.lte=2013-04-20;updatedAt.gte=2017-04-20" + + "&msisdn.regex=A;msisdn%3d~B;msisdn%3D~C" + + "&a.gt=1&b.gte=2&c.lt=3&d.lte=4&e.regex=5" + + "&f.GT=1&g.GTE=2&h.LT=3&i.LTE=4&j.REGEX=5" + + "&k%3E1&l%3E%3D2&m%3C3&l%3C%3D4&o%3D~5&p%3D%3D6" + + "&rootAttr.nestedAttr=value" + + "&resourceCharacteristic[%40type%3DIDS].value.operator_id=OpOp" + + "&order=1" + + "'" + + ",'[" + + "Filter(AND [operatorId] EQ [one-person]), " + + "Filter(OR [operatorId] EQ [two-person]), " + + "Filter(AND [createdAt] GT [2013-04-20]), " + + "Filter(AND [createdAt] LT [2017-04-20]), " + + "Filter(AND [updatedAt] LTE [2013-04-20]), " + + "Filter(OR [updatedAt] GTE [2017-04-20]), " + + "Filter(AND [msisdn] REGEX [A]), " + + "Filter(OR [msisdn] REGEX [B]), " + + "Filter(OR [msisdn] REGEX [C]), " + + "Filter(AND [a] GT [1]), " + + "Filter(AND [b] GTE [2]), " + + "Filter(AND [c] LT [3]), " + + "Filter(AND [d] LTE [4]), " + + "Filter(AND [e] REGEX [5]), " + + "Filter(AND [f] GT [1]), " + + "Filter(AND [g] GTE [2]), " + + "Filter(AND [h] LT [3]), " + + "Filter(AND [i] LTE [4]), " + + "Filter(AND [j] REGEX [5]), " + + "Filter(AND [k] GT [1]), " + + "Filter(AND [l] GTE [2]), " + + "Filter(AND [m] LT [3]), " + + "Filter(AND [l] LTE [4]), " + + "Filter(AND [o] REGEX [5]), " + + "Filter(AND [p] EQ [6]), " + + "Filter(AND [rootAttr.nestedAttr] EQ [value]), " + + "Filter(AND [resourceCharacteristic[@type=IDS].value.operator_id] EQ [OpOp]), " + + "Filter(AND [order] EQ [1])" + + "]'", + ), + ], + method = [ + MethodSource( + "fieldSuffixesAllCases", + ), + ], + ) + internal fun test1(variant: Implementation, query: String, expected: String) { + assertThat(variant.get().parseQuery(query)).hasToString(expected) + } + + companion object { + @JvmStatic + @Suppress("unused") + fun fieldSuffixesAllCases(): List { + + fun String.mixCases(): Iterable { + return (0..1.shl(length)).map { mask -> + toCharArray() + .mapIndexed { i, c -> if (mask.shr(i) and 1 == 1) c.uppercase() else c.lowercase() } + .joinToString(separator = "") + } + .toSet() + } + + return mapOf( + "EQ" to ".eq", "REGEX" to ".regex", + "GT" to ".gt", "LT" to ".lt", + "GTE" to ".gte", "LTE" to ".lte", + ) + .mapKeys { (op, _) -> "[Filter(AND [field] $op [value])]" } + .flatMap { (filter, suffix) -> + suffix.mixCases() + .map { "field$it=value" } + .map { it to filter } + } + .map { (query, filter) -> Arguments.of(query, filter) } + } + } + +}