blob: 36593df1b2f1a8b795255af97aed98c275a2f532 [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.avatica.util.Spaces;
import org.apache.calcite.util.Pair;
import org.apache.calcite.util.Sources;
import org.apache.calcite.util.Util;
import org.apache.calcite.util.XmlOutput;
import com.google.common.cache.CacheBuilder;
import com.google.common.cache.CacheLoader;
import com.google.common.cache.LoadingCache;
import org.junit.jupiter.api.Assertions;
import org.opentest4j.AssertionFailedError;
import org.w3c.dom.CDATASection;
import org.w3c.dom.Comment;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.NamedNodeMap;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import org.w3c.dom.Text;
import org.xml.sax.SAXException;
import java.io.File;
import java.io.IOException;
import java.io.Writer;
import java.net.URL;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;
/**
* A collection of resources used by tests.
*
* <p>Loads files containing test input and output into memory. If there are
* differences, writes out a log file containing the actual output.
*
* <p>Typical usage is as follows. A test case class defines a method
*
* <blockquote><pre><code>
* package com.acme.test;
* &nbsp;
* public class MyTest extends TestCase {
* public DiffRepository getDiffRepos() {
* return DiffRepository.lookup(MyTest.class);
* }
* &nbsp;
* &#64;Test public void testToUpper() {
* getDiffRepos().assertEquals("${result}", "${string}");
* }
* &nbsp;
* &#64;Test public void testToLower() {
* getDiffRepos().assertEquals("Multi-line\nstring", "${string}");
* }
* }
* </code></pre></blockquote>
*
* <p>There is an accompanying reference file named after the class,
* <code>src/test/resources/com/acme/test/MyTest.xml</code>:</p>
*
* <blockquote><pre><code>
* &lt;Root&gt;
* &lt;TestCase name="testToUpper"&gt;
* &lt;Resource name="string"&gt;
* &lt;![CDATA[String to be converted to upper case]]&gt;
* &lt;/Resource&gt;
* &lt;Resource name="result"&gt;
* &lt;![CDATA[STRING TO BE CONVERTED TO UPPER CASE]]&gt;
* &lt;/Resource&gt;
* &lt;/TestCase&gt;
* &lt;TestCase name="testToLower"&gt;
* &lt;Resource name="result"&gt;
* &lt;![CDATA[multi-line
* string]]&gt;
* &lt;/Resource&gt;
* &lt;/TestCase&gt;
* &lt;/Root&gt;
*
* </code></pre></blockquote>
*
* <p>If any of the test cases fails, a log file is generated, called
* <code>target/surefire/com/acme/test/MyTest.xml</code>, containing the actual
* output.</p>
*
* <p>(Maven sometimes removes this file; if it is not present, run maven with
* an extra {@code -X} flag.
* See <a href="http://jira.codehaus.org/browse/SUREFIRE-846">[SUREFIRE-846]</a>
* for details.)</p>
*
* <p>The log
* file is otherwise identical to the reference log, so once the log file has
* been verified, it can simply be copied over to become the new reference
* log:</p>
*
* <blockquote><code>cp target/surefire/com/acme/test/MyTest.xml
* src/test/resources/com/acme/test/MyTest.xml</code></blockquote>
*
* <p>If a resource or test case does not exist, <code>DiffRepository</code>
* creates them in the log file. Because DiffRepository is so forgiving, it is
* very easy to create new tests and test cases.</p>
*
* <p>The {@link #lookup} method ensures that all test cases share the same
* instance of the repository. This is important more than one one test case
* fails. The shared instance ensures that the generated
* <code>target/surefire/com/acme/test/MyTest.xml</code>
* file contains the actual for <em>both</em> test cases.
*/
public class DiffRepository {
//~ Static fields/initializers ---------------------------------------------
/*
Example XML document:
<Root>
<TestCase name="testFoo">
<Resource name="sql">
<![CDATA[select from emps]]>
</Resource>
<Resource name="plan">
<![CDATA[MockTableImplRel.FENNEL_EXEC(table=[SALES, EMP])]]>
</Resource>
</TestCase>
<TestCase name="testBar">
<Resource name="sql">
<![CDATA[select * from depts where deptno = 10]]>
</Resource>
<Resource name="output">
<![CDATA[10, 'Sales']]>
</Resource>
</TestCase>
</Root>
*/
private static final String ROOT_TAG = "Root";
private static final String TEST_CASE_TAG = "TestCase";
private static final String TEST_CASE_NAME_ATTR = "name";
private static final String TEST_CASE_OVERRIDES_ATTR = "overrides";
private static final String RESOURCE_TAG = "Resource";
private static final String RESOURCE_NAME_ATTR = "name";
/**
* Holds one diff-repository per class. It is necessary for all test cases in
* the same class to share the same diff-repository: if the repository gets
* loaded once per test case, then only one diff is recorded.
*/
private static final LoadingCache<Key, DiffRepository> REPOSITORY_CACHE =
CacheBuilder.newBuilder().build(CacheLoader.from(Key::toRepo));
//~ Instance fields --------------------------------------------------------
private final DiffRepository baseRepository;
private final int indent;
private Document doc;
private final Element root;
private final File logFile;
private final Filter filter;
/**
* Creates a DiffRepository.
*
* @param refFile Reference file
* @param logFile Log file
* @param baseRepository Parent repository or null
* @param filter Filter or null
*/
private DiffRepository(
URL refFile,
File logFile,
DiffRepository baseRepository,
Filter filter) {
this.baseRepository = baseRepository;
this.filter = filter;
if (refFile == null) {
throw new IllegalArgumentException("url must not be null");
}
this.logFile = logFile;
// Load the document.
DocumentBuilderFactory fac = DocumentBuilderFactory.newInstance();
try {
DocumentBuilder docBuilder = fac.newDocumentBuilder();
try {
// Parse the reference file.
this.doc = docBuilder.parse(refFile.openStream());
// Don't write a log file yet -- as far as we know, it's still
// identical.
} catch (IOException e) {
// There's no reference file. Create and write a log file.
this.doc = docBuilder.newDocument();
this.doc.appendChild(
doc.createElement(ROOT_TAG));
flushDoc();
}
this.root = doc.getDocumentElement();
validate(this.root);
} catch (ParserConfigurationException | SAXException e) {
throw new RuntimeException("error while creating xml parser", e);
}
indent = logFile.getPath().contains("RelOptRulesTest")
|| logFile.getPath().contains("SqlToRelConverterTest")
|| logFile.getPath().contains("SqlLimitsTest") ? 4 : 2;
}
//~ Methods ----------------------------------------------------------------
private static URL findFile(Class<?> clazz, final String suffix) {
// The reference file for class "com.foo.Bar" is "com/foo/Bar.xml"
String rest = "/" + clazz.getName().replace('.', File.separatorChar)
+ suffix;
return clazz.getResource(rest);
}
/**
* Expands a string containing one or more variables. (Currently only works
* if there is one variable.)
*/
public synchronized String expand(String tag, String text) {
if (text == null) {
return null;
} else if (text.startsWith("${")
&& text.endsWith("}")) {
final String testCaseName = getCurrentTestCaseName(true);
final String token = text.substring(2, text.length() - 1);
if (tag == null) {
tag = token;
}
assert token.startsWith(tag) : "token '" + token
+ "' does not match tag '" + tag + "'";
String expanded = get(testCaseName, token);
if (expanded == null) {
// Token is not specified. Return the original text: this will
// cause a diff, and the actual value will be written to the
// log file.
return text;
}
if (filter != null) {
expanded =
filter.filter(this, testCaseName, tag, text, expanded);
}
return expanded;
} else {
// Make sure what appears in the resource file is consistent with
// what is in the Java. It helps to have a redundant copy in the
// resource file.
final String testCaseName = getCurrentTestCaseName(true);
if (baseRepository == null || baseRepository.get(testCaseName, tag) == null) {
set(tag, text);
}
return text;
}
}
/**
* Sets the value of a given resource of the current test case.
*
* @param resourceName Name of the resource, e.g. "sql"
* @param value Value of the resource
*/
public synchronized void set(String resourceName, String value) {
assert resourceName != null;
final String testCaseName = getCurrentTestCaseName(true);
update(testCaseName, resourceName, value);
}
public void amend(String expected, String actual) {
if (expected.startsWith("${")
&& expected.endsWith("}")) {
String token = expected.substring(2, expected.length() - 1);
set(token, actual);
}
}
/**
* Returns a given resource from a given test case.
*
* @param testCaseName Name of test case, e.g. "testFoo"
* @param resourceName Name of resource, e.g. "sql", "plan"
* @return The value of the resource, or null if not found
*/
private synchronized String get(
final String testCaseName,
String resourceName) {
Element testCaseElement = getTestCaseElement(testCaseName, true, null);
if (testCaseElement == null) {
if (baseRepository != null) {
return baseRepository.get(testCaseName, resourceName);
} else {
return null;
}
}
final Element resourceElement =
getResourceElement(testCaseElement, resourceName);
if (resourceElement != null) {
return getText(resourceElement);
}
return null;
}
/**
* Returns the text under an element.
*/
private static String getText(Element element) {
// If there is a <![CDATA[ ... ]]> child, return its text and ignore
// all other child elements.
final NodeList childNodes = element.getChildNodes();
for (int i = 0; i < childNodes.getLength(); i++) {
Node node = childNodes.item(i);
if (node instanceof CDATASection) {
return node.getNodeValue();
}
}
// Otherwise return all the text under this element (including
// whitespace).
StringBuilder buf = new StringBuilder();
for (int i = 0; i < childNodes.getLength(); i++) {
Node node = childNodes.item(i);
if (node instanceof Text) {
buf.append(((Text) node).getWholeText());
}
}
return buf.toString();
}
/**
* Returns the &lt;TestCase&gt; element corresponding to the current test
* case.
*
* @param testCaseName Name of test case
* @param checkOverride Make sure that if an element overrides an element in
* a base repository, it has overrides="true"
* @return TestCase element, or null if not found
*/
private synchronized Element getTestCaseElement(
final String testCaseName,
boolean checkOverride,
List<Pair<String, Element>> elements) {
final NodeList childNodes = root.getChildNodes();
for (int i = 0; i < childNodes.getLength(); i++) {
Node child = childNodes.item(i);
if (child.getNodeName().equals(TEST_CASE_TAG)) {
Element testCase = (Element) child;
final String name = testCase.getAttribute(TEST_CASE_NAME_ATTR);
if (testCaseName.equals(name)) {
if (checkOverride
&& (baseRepository != null)
&& (baseRepository.getTestCaseElement(testCaseName, false, null) != null)
&& !"true".equals(
testCase.getAttribute(TEST_CASE_OVERRIDES_ATTR))) {
throw new RuntimeException(
"TestCase '" + testCaseName + "' overrides a "
+ "test case in the base repository, but does "
+ "not specify 'overrides=true'");
}
return testCase;
}
if (elements != null) {
elements.add(Pair.of(name, testCase));
}
}
}
return null;
}
/**
* Returns the name of the current test case by looking up the call stack for
* a method whose name starts with "test", for example "testFoo".
*
* @param fail Whether to fail if no method is found
* @return Name of current test case, or null if not found
*/
private String getCurrentTestCaseName(boolean fail) {
// REVIEW jvs 12-Mar-2006: Too clever by half. Someone might not know
// about this and use a private helper method whose name also starts
// with test. Perhaps just require them to pass in getName() from the
// calling TestCase's setUp method and store it in a thread-local,
// failing here if they forgot?
// Clever, this. Dump the stack and look up it for a method which
// looks like a test case name, e.g. "testFoo".
final StackTraceElement[] stackTrace;
Throwable runtimeException = new Throwable();
runtimeException.fillInStackTrace();
stackTrace = runtimeException.getStackTrace();
for (StackTraceElement stackTraceElement : stackTrace) {
final String methodName = stackTraceElement.getMethodName();
if (methodName.startsWith("test")) {
return methodName;
}
}
if (fail) {
throw new RuntimeException("no test case on current call stack");
} else {
return null;
}
}
public void assertEquals(String tag, String expected, String actual) {
final String testCaseName = getCurrentTestCaseName(true);
String expected2 = expand(tag, expected);
if (expected2 == null) {
update(testCaseName, expected, actual);
throw new AssertionError("reference file does not contain resource '"
+ expected + "' for test case '" + testCaseName + "'");
} else {
try {
// TODO jvs 25-Apr-2006: reuse bulk of
// DiffTestCase.diffTestLog here; besides newline
// insensitivity, it can report on the line
// at which the first diff occurs, which is useful
// for largish snippets
String expected2Canonical =
expected2.replace(Util.LINE_SEPARATOR, "\n");
String actualCanonical =
actual.replace(Util.LINE_SEPARATOR, "\n");
Assertions.assertEquals(expected2Canonical, actualCanonical, tag);
} catch (AssertionFailedError e) {
amend(expected, actual);
throw e;
}
}
}
/**
* Creates a new document with a given resource.
*
* <p>This method is synchronized, in case two threads are running test
* cases of this test at the same time.
*
* @param testCaseName Test case name
* @param resourceName Resource name
* @param value New value of resource
*/
private synchronized void update(
String testCaseName,
String resourceName,
String value) {
final List<Pair<String, Element>> map = new ArrayList<>();
Element testCaseElement = getTestCaseElement(testCaseName, true, map);
if (testCaseElement == null) {
testCaseElement = doc.createElement(TEST_CASE_TAG);
testCaseElement.setAttribute(TEST_CASE_NAME_ATTR, testCaseName);
Node refElement = ref(testCaseName, map);
root.insertBefore(testCaseElement, refElement);
}
Element resourceElement =
getResourceElement(testCaseElement, resourceName, true);
if (resourceElement == null) {
resourceElement = doc.createElement(RESOURCE_TAG);
resourceElement.setAttribute(RESOURCE_NAME_ATTR, resourceName);
testCaseElement.appendChild(resourceElement);
} else {
removeAllChildren(resourceElement);
}
if (!value.equals("")) {
resourceElement.appendChild(doc.createCDATASection(value));
}
// Write out the document.
flushDoc();
}
private Node ref(String testCaseName, List<Pair<String, Element>> map) {
if (map.isEmpty()) {
return null;
}
// Compute the position that the new element should be if the map were
// sorted.
int i = 0;
final List<String> names = Pair.left(map);
for (String s : names) {
if (s.compareToIgnoreCase(testCaseName) <= 0) {
++i;
}
}
// Starting at a proportional position in the list,
// move forwards through lesser names, then
// move backwards through greater names.
//
// The intended effect is that if the list is already sorted, the new item
// will end up in exactly the right position, and if the list is not sorted,
// the new item will end up in approximately the right position.
while (i < map.size()
&& names.get(i).compareToIgnoreCase(testCaseName) < 0) {
++i;
}
if (i >= map.size() - 1) {
return null;
}
while (i >= 0 && names.get(i).compareToIgnoreCase(testCaseName) > 0) {
--i;
}
return map.get(i + 1).right;
}
/**
* Flushes the reference document to the file system.
*/
private void flushDoc() {
try {
boolean b = logFile.getParentFile().mkdirs();
Util.discard(b);
try (Writer w = Util.printWriter(logFile)) {
write(doc, w, indent);
}
} catch (IOException e) {
throw new RuntimeException("error while writing test reference log '"
+ logFile + "'", e);
}
}
/** Validates the root element. */
private static void validate(Element root) {
if (!root.getNodeName().equals(ROOT_TAG)) {
throw new RuntimeException("expected root element of type '" + ROOT_TAG
+ "', but found '" + root.getNodeName() + "'");
}
// Make sure that there are no duplicate test cases.
final Set<String> testCases = new HashSet<>();
final NodeList childNodes = root.getChildNodes();
for (int i = 0; i < childNodes.getLength(); i++) {
Node child = childNodes.item(i);
if (child.getNodeName().equals(TEST_CASE_TAG)) {
Element testCase = (Element) child;
final String name = testCase.getAttribute(TEST_CASE_NAME_ATTR);
if (!testCases.add(name)) {
throw new RuntimeException("TestCase '" + name + "' is duplicate");
}
}
}
}
/**
* Returns a given resource from a given test case.
*
* @param testCaseElement The enclosing TestCase element, e.g. <code>
* &lt;TestCase name="testFoo"&gt;</code>.
* @param resourceName Name of resource, e.g. "sql", "plan"
* @return The value of the resource, or null if not found
*/
private static Element getResourceElement(
Element testCaseElement,
String resourceName) {
return getResourceElement(testCaseElement, resourceName, false);
}
/**
* Returns a given resource from a given test case.
*
* @param testCaseElement The enclosing TestCase element, e.g. <code>
* &lt;TestCase name="testFoo"&gt;</code>.
* @param resourceName Name of resource, e.g. "sql", "plan"
* @param killYoungerSiblings Whether to remove resources with the same
* name and the same parent that are eclipsed
* @return The value of the resource, or null if not found
*/
private static Element getResourceElement(Element testCaseElement,
String resourceName, boolean killYoungerSiblings) {
final NodeList childNodes = testCaseElement.getChildNodes();
Element found = null;
final List<Node> kills = new ArrayList<>();
for (int i = 0; i < childNodes.getLength(); i++) {
Node child = childNodes.item(i);
if (child.getNodeName().equals(RESOURCE_TAG)
&& resourceName.equals(
((Element) child).getAttribute(RESOURCE_NAME_ATTR))) {
if (found == null) {
found = (Element) child;
} else if (killYoungerSiblings) {
kills.add(child);
}
}
}
for (Node kill : kills) {
testCaseElement.removeChild(kill);
}
return found;
}
private static void removeAllChildren(Element element) {
final NodeList childNodes = element.getChildNodes();
while (childNodes.getLength() > 0) {
element.removeChild(childNodes.item(0));
}
}
/**
* Serializes an XML document as text.
*
* <p>FIXME: I'm sure there's a library call to do this, but I'm danged if I
* can find it. -- jhyde, 2006/2/9.
*/
private static void write(Document doc, Writer w, int indent) {
final XmlOutput out = new XmlOutput(w);
out.setGlob(true);
out.setIndentString(Spaces.of(indent));
writeNode(doc, out);
}
private static void writeNode(Node node, XmlOutput out) {
final NodeList childNodes;
switch (node.getNodeType()) {
case Node.DOCUMENT_NODE:
out.print("<?xml version=\"1.0\" ?>\n");
childNodes = node.getChildNodes();
for (int i = 0; i < childNodes.getLength(); i++) {
Node child = childNodes.item(i);
writeNode(child, out);
}
// writeNode(((Document) node).getDocumentElement(),
// out);
break;
case Node.ELEMENT_NODE:
Element element = (Element) node;
final String tagName = element.getTagName();
out.beginBeginTag(tagName);
// Attributes.
final NamedNodeMap attributeMap = element.getAttributes();
for (int i = 0; i < attributeMap.getLength(); i++) {
final Node att = attributeMap.item(i);
out.attribute(
att.getNodeName(),
att.getNodeValue());
}
out.endBeginTag(tagName);
// Write child nodes, ignoring attributes but including text.
childNodes = node.getChildNodes();
for (int i = 0; i < childNodes.getLength(); i++) {
Node child = childNodes.item(i);
if (child.getNodeType() == Node.ATTRIBUTE_NODE) {
continue;
}
writeNode(child, out);
}
out.endTag(tagName);
break;
case Node.ATTRIBUTE_NODE:
out.attribute(
node.getNodeName(),
node.getNodeValue());
break;
case Node.CDATA_SECTION_NODE:
CDATASection cdata = (CDATASection) node;
out.cdata(
cdata.getNodeValue(),
true);
break;
case Node.TEXT_NODE:
Text text = (Text) node;
final String wholeText = text.getNodeValue();
if (!isWhitespace(wholeText)) {
out.cdata(wholeText, false);
}
break;
case Node.COMMENT_NODE:
Comment comment = (Comment) node;
out.print("<!--" + comment.getNodeValue() + "-->\n");
break;
default:
throw new RuntimeException("unexpected node type: " + node.getNodeType()
+ " (" + node + ")");
}
}
private static boolean isWhitespace(String text) {
for (int i = 0, count = text.length(); i < count; ++i) {
final char c = text.charAt(i);
switch (c) {
case ' ':
case '\t':
case '\n':
break;
default:
return false;
}
}
return true;
}
/**
* Finds the repository instance for a given class, with no base
* repository or filter.
*
* @param clazz Test case class
* @return The diff repository shared between test cases in this class.
*/
public static DiffRepository lookup(Class<?> clazz) {
return lookup(clazz, null);
}
/**
* Finds the repository instance for a given class and inheriting from
* a given repository.
*
* @param clazz Test case class
* @param baseRepository Base class of test class
* @return The diff repository shared between test cases in this class.
*/
public static DiffRepository lookup(
Class<?> clazz,
DiffRepository baseRepository) {
return lookup(clazz, baseRepository, null);
}
/**
* Finds the repository instance for a given class.
*
* <p>It is important that all test cases in a class share the same
* repository instance. This ensures that, if two or more test cases fail,
* the log file will contains the actual results of both test cases.
*
* <p>The <code>baseRepository</code> parameter is useful if the test is an
* extension to a previous test. If the test class has a base class which
* also has a repository, specify the repository here. DiffRepository will
* look for resources in the base class if it cannot find them in this
* repository. If test resources from test cases in the base class are
* missing or incorrect, it will not write them to the log file -- you
* probably need to fix the base test.
*
* <p>Use the <code>filter</code> parameter if you expect the test to
* return results slightly different than in the repository. This happens
* if the behavior of a derived test is slightly different than a base
* test. If you do not specify a filter, no filtering will happen.
*
* @param clazz Test case class
* @param baseRepository Base repository
* @param filter Filters each string returned by the repository
* @return The diff repository shared between test cases in this class.
*/
public static DiffRepository lookup(Class<?> clazz,
DiffRepository baseRepository,
Filter filter) {
final Key key = new Key(clazz, baseRepository, filter);
return REPOSITORY_CACHE.getUnchecked(key);
}
/**
* Callback to filter strings before returning them.
*/
public interface Filter {
/**
* Filters a string.
*
* @param diffRepository Repository
* @param testCaseName Test case name
* @param tag Tag being expanded
* @param text Text being expanded
* @param expanded Expanded text
* @return Expanded text after filtering
*/
String filter(
DiffRepository diffRepository,
String testCaseName,
String tag,
String text,
String expanded);
}
/** Cache key. */
private static class Key {
private final Class<?> clazz;
private final DiffRepository baseRepository;
private final Filter filter;
Key(Class<?> clazz, DiffRepository baseRepository, Filter filter) {
this.clazz = Objects.requireNonNull(clazz);
this.baseRepository = baseRepository;
this.filter = filter;
}
@Override public int hashCode() {
return Objects.hash(clazz, baseRepository, filter);
}
@Override public boolean equals(Object obj) {
return this == obj
|| obj instanceof Key
&& clazz.equals(((Key) obj).clazz)
&& Objects.equals(baseRepository, ((Key) obj).baseRepository)
&& Objects.equals(filter, ((Key) obj).filter);
}
DiffRepository toRepo() {
final URL refFile = findFile(clazz, ".xml");
final String refFilePath = Sources.of(refFile).file().getAbsolutePath();
final String logFilePath = refFilePath.replace(".xml", "_actual.xml");
final File logFile = new File(logFilePath);
assert !refFilePath.equals(logFile.getAbsolutePath());
return new DiffRepository(refFile, logFile, baseRepository, filter);
}
}
}