'"),
+ 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 literal | Description | URL 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~ |
+ *
+ *
+ * .eq |
+ * equal 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) }
+ }
+ }
+
+}