diff --git a/dependencies.properties b/dependencies.properties index 7a44a5e13..453c4502b 100644 --- a/dependencies.properties +++ b/dependencies.properties @@ -4,8 +4,10 @@ com.amazonaws:aws-java-sdk-cloudwatch = 1.12.272 com.fasterxml.jackson.core:jackson-core = 2.13.3 com.fasterxml.jackson.core:jackson-databind = 2.13.3 com.fasterxml.jackson.dataformat:jackson-dataformat-smile = 2.13.3 +com.github.ben-manes.caffeine:caffeine = 2.9.3 com.google.inject.extensions:guice-servlet = 5.1.0 com.google.inject:guice = 5.1.0 +com.google.re2j:re2j = 1.7 com.netflix.archaius:archaius2-core = 2.3.16 com.netflix.frigga:frigga = 0.25.0 com.netflix.governator:governator = 1.17.12 @@ -23,6 +25,7 @@ org.apache.logging.log4j:log4j-core = 2.18.0 org.apache.logging.log4j:log4j-jcl = 2.18.0 org.apache.logging.log4j:log4j-jul = 2.18.0 org.apache.logging.log4j:log4j-slf4j-impl = 2.18.0 +org.hsqldb:hsqldb = 2.6.1 org.junit.jupiter:junit-jupiter-engine = 5.9.0 org.slf4j:slf4j-api = 1.7.36 org.slf4j:slf4j-nop = 1.7.36 diff --git a/spectator-api/build.gradle b/spectator-api/build.gradle index 08c015ba0..543b64ad2 100644 --- a/spectator-api/build.gradle +++ b/spectator-api/build.gradle @@ -1,9 +1,10 @@ dependencies { testImplementation files("$projectDir/src/test/lib/compatibility-0.68.0.jar") - testImplementation "org.hsqldb:hsqldb:2.6.1" - jmh "com.google.re2j:re2j:1.7" - jmh "com.github.ben-manes.caffeine:caffeine:2.9.3" + testImplementation "com.google.re2j:re2j" + testImplementation "org.hsqldb:hsqldb" + jmh "com.google.re2j:re2j" + jmh "com.github.ben-manes.caffeine:caffeine" } javadoc { diff --git a/spectator-api/src/main/java/com/netflix/spectator/impl/PatternExpr.java b/spectator-api/src/main/java/com/netflix/spectator/impl/PatternExpr.java new file mode 100644 index 000000000..071d35c1e --- /dev/null +++ b/spectator-api/src/main/java/com/netflix/spectator/impl/PatternExpr.java @@ -0,0 +1,291 @@ +/* + * Copyright 2014-2022 Netflix, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.netflix.spectator.impl; + +import java.util.List; +import java.util.Objects; +import java.util.StringJoiner; + +/** + * Represents an expression of simpler patterns combined with AND, OR, and NOT clauses. + * This can be used for rewriting complex regular expressions to simpler patterns for + * data stores that either support a more limited set of pattern or have better optimizations + * in place for simpler operations like starts with or contains. + */ +public interface PatternExpr { + + /** + * Returns true if the expression matches the value. This is a helper mostly used for testing + * to ensure the matching logic is consistent with the original regular expression. + */ + boolean matches(String value); + + /** + * Convert this expression into a query string. A common example would be to implement + * an encoder that would convert it into part of a WHERE clause for a SQL DB. + */ + default String toQueryString(Encoder encoder) { + StringBuilder builder = new StringBuilder(); + buildQueryString(encoder, builder); + return builder.toString(); + } + + /** + * Convert this expression into a query string. A common example would be to implement + * an encoder that would convert it into part of a WHERE clause for a SQL DB. + */ + default void buildQueryString(Encoder encoder, StringBuilder builder) { + if (this instanceof Regex) { + Regex re = (Regex) this; + builder.append(encoder.regex(re.matcher())); + } else if (this instanceof And) { + List exprs = ((And) this).exprs(); + int size = exprs.size(); + if (size == 1) { + exprs.get(0).buildQueryString(encoder, builder); + } else if (size > 1) { + builder.append(encoder.startAnd()); + exprs.get(0).buildQueryString(encoder, builder); + for (int i = 1; i < size; ++i) { + builder.append(encoder.separatorAnd()); + exprs.get(i).buildQueryString(encoder, builder); + } + builder.append(encoder.endAnd()); + } + } else if (this instanceof Or) { + List exprs = ((Or) this).exprs(); + int size = exprs.size(); + if (size == 1) { + exprs.get(0).buildQueryString(encoder, builder); + } else if (size > 1) { + builder.append(encoder.startOr()); + exprs.get(0).buildQueryString(encoder, builder); + for (int i = 1; i < size; ++i) { + builder.append(encoder.separatorOr()); + exprs.get(i).buildQueryString(encoder, builder); + } + builder.append(encoder.endOr()); + } + } else if (this instanceof Not) { + builder.append(encoder.startNot()); + ((Not) this).expr().buildQueryString(encoder, builder); + builder.append(encoder.endNot()); + } + } + + /** + * Encoder to map a pattern expression to an expression for some other language. + */ + interface Encoder { + + /** Encode a simple pattern match. */ + String regex(PatternMatcher matcher); + + /** Encode the start for a chain of clauses combined with AND. */ + String startAnd(); + + /** Encode the separator for a chain of clauses combined with AND. */ + String separatorAnd(); + + /** Encode the end for a chain of clauses combined with AND. */ + String endAnd(); + + /** Encode the start for a chain of clauses combined with OR. */ + String startOr(); + + /** Encode the separator for a chain of clauses combined with OR. */ + String separatorOr(); + + /** Encode the end for a chain of clauses combined with OR. */ + String endOr(); + + /** Encode the start for a NOT clause. */ + String startNot(); + + /** Encode the end for a NOT clause. */ + String endNot(); + } + + /** A simple expression that performs a single pattern match. */ + static PatternExpr simple(PatternMatcher matcher) { + return new Regex(matcher); + } + + /** An expression that performs a logical AND of the listed sub-expressions. */ + static PatternExpr and(List exprs) { + if (exprs == null) + return null; + int size = exprs.size(); + Preconditions.checkArg(size > 0, "exprs list cannot be empty"); + return size == 1 ? exprs.get(0) : new And(exprs); + } + + /** An expression that performs a logical OR of the listed sub-expressions. */ + static PatternExpr or(List exprs) { + if (exprs == null) + return null; + int size = exprs.size(); + Preconditions.checkArg(size > 0, "exprs list cannot be empty"); + return size == 1 ? exprs.get(0) : new Or(exprs); + } + + /** An expression that inverts the match of the sub-expression. */ + static PatternExpr not(PatternExpr expr) { + return new Not(expr); + } + + final class Regex implements PatternExpr { + + private final PatternMatcher matcher; + + Regex(PatternMatcher matcher) { + this.matcher = Preconditions.checkNotNull(matcher, "matcher"); + } + + public PatternMatcher matcher() { + return matcher; + } + + @Override public boolean matches(String str) { + return matcher.matches(str); + } + + @Override public String toString() { + return "'" + matcher + "'"; + } + + @Override public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof Regex)) return false; + Regex regex = (Regex) o; + return matcher.equals(regex.matcher); + } + + @Override public int hashCode() { + return Objects.hash(matcher); + } + } + + final class And implements PatternExpr { + + private final List exprs; + + And(List exprs) { + this.exprs = Preconditions.checkNotNull(exprs, "exprs"); + } + + public List exprs() { + return exprs; + } + + @Override public boolean matches(String str) { + for (PatternExpr expr : exprs) { + if (!expr.matches(str)) { + return false; + } + } + return true; + } + + @Override public String toString() { + StringJoiner joiner = new StringJoiner(" AND ", "(", ")"); + exprs.forEach(expr -> joiner.add(expr.toString())); + return joiner.toString(); + } + + @Override public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof And)) return false; + And and = (And) o; + return exprs.equals(and.exprs); + } + + @Override public int hashCode() { + return Objects.hash(exprs); + } + } + + final class Or implements PatternExpr { + + private final List exprs; + + Or(List exprs) { + this.exprs = Preconditions.checkNotNull(exprs, "exprs"); + } + + public List exprs() { + return exprs; + } + + @Override public boolean matches(String str) { + for (PatternExpr expr : exprs) { + if (expr.matches(str)) { + return true; + } + } + return false; + } + + @Override public String toString() { + StringJoiner joiner = new StringJoiner(" OR ", "(", ")"); + exprs.forEach(expr -> joiner.add(expr.toString())); + return joiner.toString(); + } + + @Override public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof Or)) return false; + Or or = (Or) o; + return exprs.equals(or.exprs); + } + + @Override public int hashCode() { + return Objects.hash(exprs); + } + } + + final class Not implements PatternExpr { + + private final PatternExpr expr; + + Not(PatternExpr expr) { + this.expr = Preconditions.checkNotNull(expr, "expr"); + } + + public PatternExpr expr() { + return expr; + } + + @Override public boolean matches(String str) { + return !expr.matches(str); + } + + @Override public String toString() { + return "NOT " + expr; + } + + @Override public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof Not)) return false; + Not not = (Not) o; + return expr.equals(not.expr); + } + + @Override public int hashCode() { + return Objects.hash(expr); + } + } +} diff --git a/spectator-api/src/main/java/com/netflix/spectator/impl/PatternMatcher.java b/spectator-api/src/main/java/com/netflix/spectator/impl/PatternMatcher.java index 5d9958a0f..20491aa7f 100644 --- a/spectator-api/src/main/java/com/netflix/spectator/impl/PatternMatcher.java +++ b/spectator-api/src/main/java/com/netflix/spectator/impl/PatternMatcher.java @@ -116,6 +116,23 @@ default List expandOrClauses(int max) { return Collections.singletonList(this); } + /** + * Attempts to rewrite this pattern to a set of simple pattern matches that can be combined + * with AND, OR, and NOT to have the same matching behavior as the original regex pattern. + * This can be useful when working with data stores that have more restricted pattern matching + * support such as RE2. + * + * @param max + * Maximum size of the expanded OR list which is needed as part of simplifying the + * overall expression. See {@link #expandOrClauses(int)} for more details. + * @return + * Expression that represents a set of simple pattern matches, or null if it is not + * possible to simplify the expression. + */ + default PatternExpr toPatternExpr(int max) { + return null; + } + /** * Returns a pattern that can be used with a SQL LIKE clause or null if this expression * cannot be expressed as a SQL pattern. Can be used to more optimally map the pattern diff --git a/spectator-api/src/main/java/com/netflix/spectator/impl/matcher/Matcher.java b/spectator-api/src/main/java/com/netflix/spectator/impl/matcher/Matcher.java index 90bf65ca4..be7d1c9c7 100644 --- a/spectator-api/src/main/java/com/netflix/spectator/impl/matcher/Matcher.java +++ b/spectator-api/src/main/java/com/netflix/spectator/impl/matcher/Matcher.java @@ -15,6 +15,7 @@ */ package com.netflix.spectator.impl.matcher; +import com.netflix.spectator.impl.PatternExpr; import com.netflix.spectator.impl.PatternMatcher; import java.util.ArrayList; @@ -67,6 +68,11 @@ default List expandOrClauses(int max) { return results; } + @Override + default PatternExpr toPatternExpr(int max) { + return PatternUtils.toPatternExpr(this, max); + } + @Override default String toSqlPattern() { return PatternUtils.toSqlPattern(this); diff --git a/spectator-api/src/main/java/com/netflix/spectator/impl/matcher/Parser.java b/spectator-api/src/main/java/com/netflix/spectator/impl/matcher/Parser.java index e096bd185..98941033e 100644 --- a/spectator-api/src/main/java/com/netflix/spectator/impl/matcher/Parser.java +++ b/spectator-api/src/main/java/com/netflix/spectator/impl/matcher/Parser.java @@ -316,6 +316,12 @@ private Matcher repeat(Matcher matcher) { String[] numbers = tokens.subSequence(start, current - 1).toString().split(","); int min = Integer.parseInt(numbers[0]); int max = (numbers.length > 1) ? Integer.parseInt(numbers[1]) : min; + if (min < 0) { + throw error("illegal repetition, min < 0"); + } + if (min > max) { + throw error("illegal repetition, min > max"); + } return new RepeatMatcher(matcher, min, max); } diff --git a/spectator-api/src/main/java/com/netflix/spectator/impl/matcher/PatternUtils.java b/spectator-api/src/main/java/com/netflix/spectator/impl/matcher/PatternUtils.java index 7d15a493d..8d8557837 100644 --- a/spectator-api/src/main/java/com/netflix/spectator/impl/matcher/PatternUtils.java +++ b/spectator-api/src/main/java/com/netflix/spectator/impl/matcher/PatternUtils.java @@ -15,12 +15,15 @@ */ package com.netflix.spectator.impl.matcher; +import com.netflix.spectator.impl.PatternExpr; import com.netflix.spectator.impl.PatternMatcher; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.function.Function; +import java.util.stream.Collectors; /** * Helper functions for working with patterns. @@ -312,6 +315,186 @@ private static List expandOr(OrMatcher matcher, int max) { return results; } + static PatternExpr toPatternExpr(Matcher matcher, int max) { + List matchers = expandOrClauses(matcher, max); + if (matchers == null) { + return null; + } else { + List exprs = matchers + .stream() + .map(PatternUtils::expandLookahead) + .collect(Collectors.toList()); + return PatternExpr.or(exprs); + } + } + + private static PatternExpr expandLookahead(Matcher matcher) { + List exprs = new ArrayList<>(); + // 0 - positive matcher to append + // 1 - negative matcher to append + // 2 - remaining matcher to keep processing + Matcher[] results = new Matcher[3]; + + // Keep processing until no lookaheads remain + removeNextLookahead(matcher, results); + while (results[2] != null) { + if (results[0] != null) + exprs.add(PatternExpr.simple(results[0])); + if (results[1] != null) + exprs.add(PatternExpr.not(PatternExpr.simple(results[1]))); + removeNextLookahead(results[2], results); + } + + // If the results array is all null, then something was incompatible, return null + // to indicate this regex cannot be simplified + if (results[0] == null && results[1] == null) { + return null; + } + + // Add final expression + if (results[0] != null) + exprs.add(PatternExpr.simple(results[0])); + if (results[1] != null) + exprs.add(PatternExpr.not(PatternExpr.simple(results[1]))); + return PatternExpr.and(exprs); + } + + private static void removeNextLookahead(Matcher matcher, Matcher[] results) { + Arrays.fill(results, null); + rewriteNextLookahead(matcher, results); + } + + @SuppressWarnings("PMD") + private static void rewriteNextLookahead(Matcher matcher, Matcher[] results) { + if (matcher instanceof IndexOfMatcher) { + IndexOfMatcher m = matcher.as(); + rewriteNextLookahead(m.next(), results); + for (int i = 0; i < results.length; ++i) { + if (results[i] != null) { + results[i] = new IndexOfMatcher(m.pattern(), results[i]); + } + } + } else if (matcher instanceof SeqMatcher) { + SeqMatcher m = matcher.as(); + Matcher[] ms = m.matchers().toArray(new Matcher[0]); + for (int i = 0; i < ms.length; ++i) { + results[0] = null; + rewriteNextLookahead(ms[i], results); + if (results[2] != null) { + // Truncated sequence with lookahead match at the end + List matchers = new ArrayList<>(i + 1); + matchers.addAll(Arrays.asList(ms).subList(0, i)); + if (results[0] != null) { + matchers.add(results[0]); + results[0] = SeqMatcher.create(matchers); + } else { + matchers.add(results[1]); + results[1] = SeqMatcher.create(matchers); + } + + // Matcher with lookahead removed + ms[i] = results[2]; + results[2] = SeqMatcher.create(ms); + break; + } else if (results[0] == null) { + // Indicates this entry of the sequence cannot be simplified + return; + } else { + results[0] = m; + } + } + } else if (matcher instanceof ZeroOrMoreMatcher) { + ZeroOrMoreMatcher m = matcher.as(); + if (containsLookahead(m.repeated())) { + return; + } + removeNextLookahead(m.next(), results); + for (int i = 0; i < results.length; ++i) { + if (results[i] != null) { + results[i] = new ZeroOrMoreMatcher(m.repeated(), results[i]); + } + } + } else if (matcher instanceof ZeroOrOneMatcher) { + ZeroOrOneMatcher m = matcher.as(); + if (containsLookahead(m.repeated())) { + return; + } + removeNextLookahead(m.next(), results); + for (int i = 0; i < results.length; ++i) { + if (results[i] != null) { + results[i] = new ZeroOrOneMatcher(m.repeated(), results[i]); + } + } + } else if (matcher instanceof RepeatMatcher) { + RepeatMatcher m = matcher.as(); + if (m.max() > 1000 || containsLookahead(m.repeated())) { + // Some engines like RE2 have limitations on the number of repetitions. Treat + // those as failures to match to the expression. + return; + } else { + results[0] = matcher; + } + } else if (matcher instanceof PositiveLookaheadMatcher) { + PositiveLookaheadMatcher m = matcher.as(); + if (containsLookahead(m.matcher())) { + return; + } + results[0] = m.matcher(); + results[2] = TrueMatcher.INSTANCE; + } else if (matcher instanceof NegativeLookaheadMatcher) { + NegativeLookaheadMatcher m = matcher.as(); + if (containsLookahead(m.matcher())) { + return; + } + results[1] = m.matcher(); + results[2] = TrueMatcher.INSTANCE; + } else if (!containsLookahead(matcher)) { + results[0] = matcher; + } + + for (int i = 0; i < results.length; ++i) { + if (results[i] != null) { + results[i] = Optimizer.optimize(results[i]); + } + } + } + + private static boolean containsLookahead(Matcher matcher) { + if (matcher instanceof NegativeLookaheadMatcher) { + return true; + } else if (matcher instanceof PositiveLookaheadMatcher) { + return true; + } else if (matcher instanceof IndexOfMatcher) { + IndexOfMatcher m = matcher.as(); + return containsLookahead(m.next()); + } else if (matcher instanceof OrMatcher) { + for (Matcher m : matcher.as().matchers()) { + if (containsLookahead(m)) { + return true; + } + } + return false; + } else if (matcher instanceof RepeatMatcher) { + RepeatMatcher m = matcher.as(); + return containsLookahead(m.repeated()); + } else if (matcher instanceof SeqMatcher) { + for (Matcher m : matcher.as().matchers()) { + if (containsLookahead(m)) { + return true; + } + } + return false; + } else if (matcher instanceof ZeroOrMoreMatcher) { + ZeroOrMoreMatcher m = matcher.as(); + return containsLookahead(m.repeated()) || containsLookahead(m.next()); + } else if (matcher instanceof ZeroOrOneMatcher) { + ZeroOrOneMatcher m = matcher.as(); + return containsLookahead(m.repeated()) || containsLookahead(m.next()); + } else { + return false; + } + } + /** Convert a matcher to a SQL pattern or return null if not possible. */ static String toSqlPattern(Matcher matcher) { StringBuilder builder = new StringBuilder(); diff --git a/spectator-api/src/test/java/com/netflix/spectator/impl/PatternExprTest.java b/spectator-api/src/test/java/com/netflix/spectator/impl/PatternExprTest.java new file mode 100644 index 000000000..3ec894097 --- /dev/null +++ b/spectator-api/src/test/java/com/netflix/spectator/impl/PatternExprTest.java @@ -0,0 +1,303 @@ +/* + * Copyright 2014-2022 Netflix, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.netflix.spectator.impl; + +import nl.jqno.equalsverifier.EqualsVerifier; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.List; + +public class PatternExprTest { + + private List list(PatternExpr... exprs) { + return Arrays.asList(exprs); + } + + private PatternExpr sampleExpr() { + return new PatternExpr.And(list( + new PatternExpr.Not( + new PatternExpr.And(list( + new PatternExpr.Regex(PatternMatcher.compile("a")), + new PatternExpr.Regex(PatternMatcher.compile("b")) + )) + ), + new PatternExpr.Or(list( + new PatternExpr.Regex(PatternMatcher.compile("c")), + new PatternExpr.Regex(PatternMatcher.compile("d")), + new PatternExpr.Regex(PatternMatcher.compile("e")) + )), + new PatternExpr.Or(list( + new PatternExpr.Regex(PatternMatcher.compile("f")) + )), + new PatternExpr.And(list( + new PatternExpr.Regex(PatternMatcher.compile("g")) + )) + )); + } + + @Test + public void infix() { + PatternExpr.Encoder encoder = new PatternExpr.Encoder() { + + @Override public String regex(PatternMatcher matcher) { + return "'" + matcher + "'"; + } + + @Override public String startAnd() { + return "("; + } + + @Override public String separatorAnd() { + return " AND "; + } + + @Override public String endAnd() { + return ")"; + } + + @Override public String startOr() { + return "("; + } + + @Override public String separatorOr() { + return " OR "; + } + + @Override public String endOr() { + return ")"; + } + + @Override public String startNot() { + return "NOT "; + } + + @Override public String endNot() { + return ""; + } + }; + + String query = sampleExpr().toQueryString(encoder); + Assertions.assertEquals("(NOT ('.*a' AND '.*b') AND ('.*c' OR '.*d' OR '.*e') AND '.*f' AND '.*g')", query); + } + + @Test + public void postfix() { + PatternExpr.Encoder encoder = new PatternExpr.Encoder() { + + @Override public String regex(PatternMatcher matcher) { + return matcher + ",:re"; + } + + @Override public String startAnd() { + return "(,"; + } + + @Override public String separatorAnd() { + return ","; + } + + @Override public String endAnd() { + return ",),:and"; + } + + @Override public String startOr() { + return "(,"; + } + + @Override public String separatorOr() { + return ","; + } + + @Override public String endOr() { + return ",),:or"; + } + + @Override public String startNot() { + return ""; + } + + @Override public String endNot() { + return ",:not"; + } + }; + + String query = sampleExpr().toQueryString(encoder); + Assertions.assertEquals("(,(,.*a,:re,.*b,:re,),:and,:not,(,.*c,:re,.*d,:re,.*e,:re,),:or,.*f,:re,.*g,:re,),:and", query); + } + + @Test + public void functions() { + PatternExpr.Encoder encoder = new PatternExpr.Encoder() { + + @Override public String regex(PatternMatcher matcher) { + return "'" + matcher + "'"; + } + + @Override public String startAnd() { + return "and("; + } + + @Override public String separatorAnd() { + return ","; + } + + @Override public String endAnd() { + return ")"; + } + + @Override public String startOr() { + return "or("; + } + + @Override public String separatorOr() { + return ","; + } + + @Override public String endOr() { + return ")"; + } + + @Override public String startNot() { + return "not("; + } + + @Override public String endNot() { + return ")"; + } + }; + + String query = sampleExpr().toQueryString(encoder); + Assertions.assertEquals("and(not(and('.*a','.*b')),or('.*c','.*d','.*e'),'.*f','.*g')", query); + } + + @Test + public void equalsContractRegex() { + EqualsVerifier.forClass(PatternExpr.Regex.class) + .withNonnullFields("matcher") + .verify(); + } + + @Test + public void equalsContractAnd() { + EqualsVerifier.forClass(PatternExpr.And.class) + .withNonnullFields("exprs") + .verify(); + } + + @Test + public void equalsContractOr() { + EqualsVerifier.forClass(PatternExpr.Or.class) + .withNonnullFields("exprs") + .verify(); + } + + @Test + public void equalsContractNot() { + EqualsVerifier.forClass(PatternExpr.Not.class) + .withNonnullFields("expr") + .verify(); + } + + @Test + public void indexOfNegativeLookahead() { + PatternExpr expr = PatternMatcher.compile("foo-(?!bar)").toPatternExpr(50); + Assertions.assertEquals("(NOT '.*foo-bar' AND '.*foo-')", expr.toString()); + } + + @Test + public void indexOfPositiveLookahead() { + PatternExpr expr = PatternMatcher.compile("foo-(?=bar)").toPatternExpr(50); + Assertions.assertEquals("('.*foo-bar' AND '.*foo-')", expr.toString()); + } + + @Test + public void seqNegativeLookahead() { + PatternExpr expr = PatternMatcher.compile("foo-(?!bar)(?!baz)").toPatternExpr(50); + Assertions.assertEquals( + "(NOT '.*foo-bar' AND NOT '.*foo-baz' AND '.*foo-')", + expr.toString()); + } + + @Test + public void seqPositiveLookahead() { + PatternExpr expr = PatternMatcher.compile("foo-(?=bar)(?=baz)").toPatternExpr(50); + Assertions.assertEquals( + "('.*foo-bar' AND '.*foo-baz' AND '.*foo-')", + expr.toString()); + } + + @Test + public void repeatedNegativeLookahead() { + PatternExpr expr = PatternMatcher.compile("(foo(?!bar)){4}").toPatternExpr(50); + Assertions.assertNull(expr); + } + + @Test + public void repeatedPositiveLookahead() { + PatternExpr expr = PatternMatcher.compile("(foo(?=bar)){4}").toPatternExpr(50); + Assertions.assertNull(expr); + } + + @Test + public void zeroOrMoreRepeatedNegativeLookahead() { + PatternExpr expr = PatternMatcher.compile("((?!bar)foo)*baz").toPatternExpr(50); + Assertions.assertNull(expr); + } + + @Test + public void zeroOrMoreRepeatedPositiveLookahead() { + PatternExpr expr = PatternMatcher.compile("((?=bar)foo)*baz").toPatternExpr(50); + Assertions.assertNull(expr); + } + + @Test + public void zeroOrMoreNextNegativeLookahead() { + PatternExpr expr = PatternMatcher.compile("(foo-)*(?!bar)").toPatternExpr(50); + Assertions.assertEquals("(NOT '(.)*(foo-)*bar' AND '(.)*(foo-)*')", expr.toString()); + } + + @Test + public void zeroOrMoreNextPositiveLookahead() { + PatternExpr expr = PatternMatcher.compile("(foo-)*(?=bar)").toPatternExpr(50); + Assertions.assertEquals("('(.)*(foo-)*bar' AND '(.)*(foo-)*')", expr.toString()); + } + + @Test + public void zeroOrOneRepeatedNegativeLookahead() { + PatternExpr expr = PatternMatcher.compile("((?!bar)foo)?baz").toPatternExpr(50); + Assertions.assertNull(expr); + } + + @Test + public void zeroOrOneRepeatedPositiveLookahead() { + PatternExpr expr = PatternMatcher.compile("((?=bar)foo)?baz").toPatternExpr(50); + Assertions.assertNull(expr); + } + + @Test + public void zeroOrOneNextNegativeLookahead() { + PatternExpr expr = PatternMatcher.compile("(foo-)?(?!bar)").toPatternExpr(50); + Assertions.assertEquals("(NOT '(.)*(?:foo-)?bar' AND '(.)*(?:foo-)?')", expr.toString()); + } + + @Test + public void zeroOrOneNextPositiveLookahead() { + PatternExpr expr = PatternMatcher.compile("(foo-)?(?=bar)").toPatternExpr(50); + Assertions.assertEquals("('(.)*(?:foo-)?bar' AND '(.)*(?:foo-)?')", expr.toString()); + } +} diff --git a/spectator-api/src/test/java/com/netflix/spectator/impl/matcher/AbstractPatternMatcherTest.java b/spectator-api/src/test/java/com/netflix/spectator/impl/matcher/AbstractPatternMatcherTest.java index ff103c282..69d8fd8de 100644 --- a/spectator-api/src/test/java/com/netflix/spectator/impl/matcher/AbstractPatternMatcherTest.java +++ b/spectator-api/src/test/java/com/netflix/spectator/impl/matcher/AbstractPatternMatcherTest.java @@ -1,5 +1,5 @@ /* - * Copyright 2014-2019 Netflix, Inc. + * Copyright 2014-2022 Netflix, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -428,4 +428,39 @@ public void repeatWithOr() { testRE("(a|b|c){1,3}d", "ababcd"); testRE("(a|b|c){1,5}d", "ababcd"); } + + @Test + public void indexOfLookahead() { + testRE("foo-(?!bar)", "foo-bar"); + testRE("foo-(?!bar)", "foo-baz"); + testRE("foo-(?=bar)", "foo-bar"); + testRE("foo-(?=bar)", "foo-baz"); + } + + @Test + public void seqLookahead() { + testRE("^[fF]oo-(?!bar)$", "foo-bar"); + testRE("^[fF]oo-(?!bar)$", "foo-baz"); + testRE("^[fF]oo-(?=bar)$", "foo-bar"); + testRE("^[fF]oo-(?=bar)$", "foo-baz"); + } + + @Test + public void repeatLookahead() { + testRE("^((?!1234)[0-9]{4}-){2,5}", "4321-1234-"); + } + + @Test + public void repeatInvalid() { + Assertions.assertThrows( + IllegalArgumentException.class, + () -> PatternMatcher.compile("a{-1,2}")); + Assertions.assertThrows( + IllegalArgumentException.class, + () -> PatternMatcher.compile("a{4,3}")); + Assertions.assertThrows( + IllegalArgumentException.class, + () -> PatternMatcher.compile("a{1000,-5}")); + testRE("a{0,100000}", "a"); + } } diff --git a/spectator-api/src/test/java/com/netflix/spectator/impl/matcher/Re2PatternMatcherTest.java b/spectator-api/src/test/java/com/netflix/spectator/impl/matcher/Re2PatternMatcherTest.java new file mode 100644 index 000000000..de1752590 --- /dev/null +++ b/spectator-api/src/test/java/com/netflix/spectator/impl/matcher/Re2PatternMatcherTest.java @@ -0,0 +1,97 @@ +/* + * Copyright 2014-2022 Netflix, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.netflix.spectator.impl.matcher; + +import com.netflix.spectator.impl.PatternExpr; +import com.netflix.spectator.impl.PatternMatcher; +import org.junit.jupiter.api.Assertions; + +import java.util.regex.Pattern; + +public class Re2PatternMatcherTest extends AbstractPatternMatcherTest { + + @Override + protected void testRE(String regex, String value) { + PatternExpr expr = PatternMatcher.compile(regex).toPatternExpr(1000); + if (expr == null) { + return; + } else { + // Validate that all remaining patterns can be processed with RE2 + expr.toQueryString(new Re2Encoder()); + } + + Pattern pattern = Pattern.compile("^.*(" + regex + ")", Pattern.DOTALL); + if (pattern.matcher(value).find()) { + Assertions.assertTrue(expr.matches(value), regex + " should match " + value); + } else { + Assertions.assertFalse(expr.matches(value), regex + " shouldn't match " + value); + } + } + + private static class Re2Encoder implements PatternExpr.Encoder { + + @Override + public String regex(PatternMatcher matcher) { + // RE2 unicode escape is \\x{NNNN} instead of \\uNNNN + String re = matcher.toString().replaceAll("\\\\u([0-9a-fA-F]{4})", "\\\\x{$1}"); + com.google.re2j.Pattern p = com.google.re2j.Pattern.compile( + "^.*(" + re + ")", + com.google.re2j.Pattern.DOTALL + ); + return p.pattern(); + } + + @Override + public String startAnd() { + return "("; + } + + @Override + public String separatorAnd() { + return " AND "; + } + + @Override + public String endAnd() { + return ")"; + } + + @Override + public String startOr() { + return "("; + } + + @Override + public String separatorOr() { + return " OR "; + } + + @Override + public String endOr() { + return ")"; + } + + @Override + public String startNot() { + return "NOT "; + } + + @Override + public String endNot() { + return ""; + } + } +}