blob: 6d21cc779990c8dc8f936cd18ec57e99b09ff0d1 [file] [log] [blame]
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to you 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 org.apache.calcite.test;
import org.apache.calcite.plan.RelOptUtil;
import org.apache.calcite.rel.RelNode;
import org.apache.calcite.rel.hint.Hintable;
import org.apache.calcite.rex.RexNode;
import org.apache.calcite.util.TestUtil;
import org.apache.calcite.util.Util;
import com.google.common.base.Preconditions;
import com.google.common.collect.Lists;
import com.google.common.collect.RangeSet;
import org.apiguardian.api.API;
import org.hamcrest.BaseMatcher;
import org.hamcrest.CoreMatchers;
import org.hamcrest.CustomTypeSafeMatcher;
import org.hamcrest.Description;
import org.hamcrest.Matcher;
import org.hamcrest.TypeSafeMatcher;
import org.hamcrest.core.Is;
import org.hamcrest.core.StringContains;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
import java.util.function.Function;
import java.util.regex.Pattern;
import java.util.stream.StreamSupport;
/**
* Matchers for testing SQL queries.
*/
public class Matchers {
private static final Pattern PATTERN = Pattern.compile(", id = [0-9]+");
private Matchers() {}
/** Allows passing the actual result from the {@code matchesSafely} method to
* the {@code describeMismatchSafely} method that will show the difference. */
private static final ThreadLocal<Object> THREAD_ACTUAL = new ThreadLocal<>();
/**
* Creates a matcher that matches if the examined result set returns the
* given collection of rows in some order.
*
* <p>Closes the result set after reading.
*
* <p>For example:
* <pre>assertThat(statement.executeQuery("select empno from emp"),
* returnsUnordered("empno=1234", "empno=100"));</pre>
*/
public static Matcher<? super ResultSet> returnsUnordered(String... lines) {
final List<String> expectedList = Lists.newArrayList(lines);
Collections.sort(expectedList);
return new CustomTypeSafeMatcher<ResultSet>(Arrays.toString(lines)) {
@Override protected void describeMismatchSafely(ResultSet item,
Description description) {
final Object value = THREAD_ACTUAL.get();
THREAD_ACTUAL.remove();
description.appendText("was ").appendValue(value);
}
protected boolean matchesSafely(ResultSet resultSet) {
final List<String> actualList = new ArrayList<>();
try {
CalciteAssert.toStringList(resultSet, actualList);
resultSet.close();
} catch (SQLException e) {
throw TestUtil.rethrow(e);
}
Collections.sort(actualList);
THREAD_ACTUAL.set(actualList);
final boolean equals = actualList.equals(expectedList);
if (!equals) {
THREAD_ACTUAL.set(actualList);
}
return equals;
}
};
}
public static <E extends Comparable> Matcher<Iterable<E>> equalsUnordered(
E... lines) {
final List<String> expectedList =
Lists.newArrayList(toStringList(Arrays.asList(lines)));
Collections.sort(expectedList);
final String description = Util.lines(expectedList);
return new CustomTypeSafeMatcher<Iterable<E>>(description) {
@Override protected void describeMismatchSafely(Iterable<E> actuals,
Description description) {
final List<String> actualList =
Lists.newArrayList(toStringList(actuals));
Collections.sort(actualList);
description.appendText("was ")
.appendValue(Util.lines(actualList));
}
protected boolean matchesSafely(Iterable<E> actuals) {
final List<String> actualList =
Lists.newArrayList(toStringList(actuals));
Collections.sort(actualList);
return actualList.equals(expectedList);
}
};
}
private static <E> Iterable<String> toStringList(Iterable<E> items) {
return StreamSupport.stream(items.spliterator(), false)
.map(Object::toString)
.collect(Util.toImmutableList());
}
/**
* Creates a matcher that matches when the examined object is within
* {@code epsilon} of the specified <code>operand</code>.
*/
public static <T extends Number> Matcher<T> within(T value, double epsilon) {
return new IsWithin<T>(value, epsilon);
}
/**
* Creates a matcher that matches if the examined value is between bounds:
* <code>min &le; value &le; max</code>.
*
* @param <T> value type
* @param min Lower bound
* @param max Upper bound
*/
public static <T extends Comparable<T>> Matcher<T> between(T min, T max) {
return new CustomTypeSafeMatcher<T>("between " + min + " and " + max) {
protected boolean matchesSafely(T item) {
return min.compareTo(item) <= 0
&& item.compareTo(max) <= 0;
}
};
}
/** Creates a matcher by applying a function to a value before calling
* another matcher. */
public static <F, T> Matcher<F> compose(Matcher<T> matcher,
Function<F, T> f) {
return new ComposingMatcher<>(matcher, f);
}
/**
* Creates a Matcher that matches when the examined string is equal to the
* specified {@code value} when all Windows-style line endings ("\r\n")
* have been converted to Unix-style line endings ("\n").
*
* <p>Thus, if {@code foo()} is a function that returns "hello{newline}world"
* in the current operating system's line endings, then
*
* <blockquote>
* assertThat(foo(), isLinux("hello\nworld"));
* </blockquote>
*
* <p>will succeed on all platforms.
*
* @see Util#toLinux(String)
*/
public static Matcher<String> isLinux(final String value) {
return compose(Is.is(value), input -> input == null ? null : Util.toLinux(input));
}
/**
* Creates a Matcher that matches a {@link RelNode} if its string
* representation, after converting Windows-style line endings ("\r\n")
* to Unix-style line endings ("\n"), is equal to the given {@code value}.
*/
public static Matcher<RelNode> hasTree(final String value) {
return compose(Is.is(value), input -> {
// Convert RelNode to a string with Linux line-endings
return Util.toLinux(RelOptUtil.toString(input));
});
}
/**
* Creates a Matcher that matches a {@link RelNode} if its string
* representation, after converting Windows-style line endings ("\r\n")
* to Unix-style line endings ("\n"), contains the given {@code value}
* as a substring.
*/
public static Matcher<RelNode> inTree(final String value) {
return compose(StringContains.containsString(value), input -> {
// Convert RelNode to a string with Linux line-endings
return Util.toLinux(RelOptUtil.toString(input));
});
}
/**
* Creates a Matcher that matches a {@link RexNode} if its string
* representation, after converting Windows-style line endings ("\r\n")
* to Unix-style line endings ("\n"), is equal to the given {@code value}.
*/
public static Matcher<RexNode> hasRex(final String value) {
return compose(Is.is(value), input -> {
// Convert RexNode to a string with Linux line-endings
return Util.toLinux(input.toString());
});
}
/**
* Creates a Matcher that matches a {@link RelNode} if its hints string
* representation is equal to the given {@code value}.
*/
public static Matcher<RelNode> hasHints(final String value) {
return compose(Is.is(value),
input -> input instanceof Hintable
? ((Hintable) input).getHints().toString()
: "[]");
}
/**
* Creates a Matcher that matches a {@link RangeSet} if its string
* representation, after changing "&#2025;" to "..",
* is equal to the given {@code value}.
*
* <p>This method is necessary because {@link RangeSet#toString()} changed
* behavior. Guava 19 - 28 used a unicode symbol;Guava 29 onwards uses "..".
*/
public static Matcher<RangeSet> isRangeSet(final String value) {
return compose(Is.is(value), input -> {
// Change all '\u2025' (a unicode symbol denoting a range) to '..',
// consistent with Guava 29+.
return input.toString().replace("\u2025", "..");
});
}
/**
* Creates a {@link Matcher} that matches execution plan and trims {@code , id=123} node ids.
* {@link RelNode#getId()} is not stable across runs, so this matcher enables to trim those.
* @param value execpted execution plan
* @return matcher
*/
@API(since = "1.22", status = API.Status.EXPERIMENTAL)
public static Matcher<String> containsWithoutNodeIds(String value) {
return compose(CoreMatchers.containsString(value), Matchers::trimNodeIds);
}
/**
* Creates a matcher that matches when the examined string is equal to the
* specified <code>operand</code> when all Windows-style line endings ("\r\n")
* have been converted to Unix-style line endings ("\n").
*
* <p>Thus, if {@code foo()} is a function that returns "hello{newline}world"
* in the current operating system's line endings, then
*
* <blockquote>
* assertThat(foo(), isLinux("hello\nworld"));
* </blockquote>
*
* <p>will succeed on all platforms.
*
* @see Util#toLinux(String)
*/
public static Matcher<String> containsStringLinux(String value) {
return compose(CoreMatchers.containsString(value), Util::toLinux);
}
public static String trimNodeIds(String s) {
return PATTERN.matcher(s).replaceAll("");
}
/**
* Creates a matcher that matches if the examined value is expected throwable.
*
* @param expected Throwable to match.
*/
public static Matcher<? super Throwable> expectThrowable(Throwable expected) {
return new BaseMatcher<Throwable>() {
@Override public boolean matches(Object item) {
if (!(item instanceof Throwable)) {
return false;
}
Throwable error = (Throwable) item;
return expected != null
&& Objects.equals(error.getClass(), expected.getClass())
&& Objects.equals(error.getMessage(), expected.getMessage());
}
@Override public void describeTo(Description description) {
description.appendText("is ").appendText(expected.toString());
}
};
}
/** Matcher that tests whether the numeric value is within a given difference
* another value.
*
* @param <T> Value type
*/
public static class IsWithin<T extends Number> extends BaseMatcher<T> {
private final T expectedValue;
private final double epsilon;
public IsWithin(T expectedValue, double epsilon) {
Preconditions.checkArgument(epsilon >= 0D);
this.expectedValue = expectedValue;
this.epsilon = epsilon;
}
public boolean matches(Object actualValue) {
return isWithin(actualValue, expectedValue, epsilon);
}
public void describeTo(Description description) {
description.appendValue(expectedValue + " +/-" + epsilon);
}
private static boolean isWithin(Object actual, Number expected,
double epsilon) {
if (actual == null) {
return expected == null;
}
if (actual.equals(expected)) {
return true;
}
final double a = ((Number) actual).doubleValue();
final double min = expected.doubleValue() - epsilon;
final double max = expected.doubleValue() + epsilon;
return min <= a && a <= max;
}
}
/** Matcher that transforms the input value using a function before
* passing to another matcher.
*
* @param <F> From type: the type of value to be matched
* @param <T> To type: type returned by function, and the resulting matcher
*/
private static class ComposingMatcher<F, T> extends TypeSafeMatcher<F> {
private final Matcher<T> matcher;
private final Function<F, T> f;
ComposingMatcher(Matcher<T> matcher, Function<F, T> f) {
this.matcher = matcher;
this.f = f;
}
protected boolean matchesSafely(F item) {
return Unsafe.matches(matcher, f.apply(item));
}
public void describeTo(Description description) {
matcher.describeTo(description);
}
@Override protected void describeMismatchSafely(F item,
Description mismatchDescription) {
mismatchDescription.appendText("was ").appendValue(f.apply(item));
}
}
}