FedXBaseTest.java

/*******************************************************************************
 * Copyright (c) 2019 Eclipse RDF4J contributors.
 *
 * All rights reserved. This program and the accompanying materials
 * are made available under the terms of the Eclipse Distribution License v1.0
 * which accompanies this distribution, and is available at
 * http://www.eclipse.org/org/documents/edl-v10.php.
 *
 * SPDX-License-Identifier: BSD-3-Clause
 *******************************************************************************/
package org.eclipse.rdf4j.federated;

import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;

import org.eclipse.rdf4j.common.io.IOUtil;
import org.eclipse.rdf4j.common.iteration.Iterations;
import org.eclipse.rdf4j.model.IRI;
import org.eclipse.rdf4j.model.Literal;
import org.eclipse.rdf4j.model.Statement;
import org.eclipse.rdf4j.model.Value;
import org.eclipse.rdf4j.model.ValueFactory;
import org.eclipse.rdf4j.model.impl.SimpleValueFactory;
import org.eclipse.rdf4j.model.util.Models;
import org.eclipse.rdf4j.query.BindingSet;
import org.eclipse.rdf4j.query.BooleanQuery;
import org.eclipse.rdf4j.query.GraphQuery;
import org.eclipse.rdf4j.query.GraphQueryResult;
import org.eclipse.rdf4j.query.Query;
import org.eclipse.rdf4j.query.QueryResults;
import org.eclipse.rdf4j.query.TupleQuery;
import org.eclipse.rdf4j.query.TupleQueryResult;
import org.eclipse.rdf4j.query.impl.ListBindingSet;
import org.eclipse.rdf4j.query.impl.MutableTupleQueryResult;
import org.eclipse.rdf4j.query.impl.TupleQueryResultBuilder;
import org.eclipse.rdf4j.query.resultio.BooleanQueryResultParserRegistry;
import org.eclipse.rdf4j.query.resultio.QueryResultFormat;
import org.eclipse.rdf4j.query.resultio.QueryResultIO;
import org.eclipse.rdf4j.query.resultio.TupleQueryResultParser;
import org.eclipse.rdf4j.repository.RepositoryException;
import org.eclipse.rdf4j.rio.RDFFormat;
import org.eclipse.rdf4j.rio.RDFParser;
import org.eclipse.rdf4j.rio.Rio;
import org.eclipse.rdf4j.rio.helpers.StatementCollector;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeAll;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.google.common.collect.Lists;

public abstract class FedXBaseTest {

	public static Logger log;

	@BeforeAll
	public static void initLogging() {

		if (System.getProperty("log4j.configurationFile") == null) {
			System.setProperty("log4j.configurationFile", "file:build/test/log4j-debug.properties");
		}

		log = LoggerFactory.getLogger(FedXBaseTest.class);
	}

	protected static final String EXAMPLE_NAMESPACE = "http://example.org/";

	protected static final ValueFactory vf = SimpleValueFactory.getInstance();

	protected String defaultNamespace = EXAMPLE_NAMESPACE;

	/**
	 * Execute a testcase, both queryFile and expectedResultFile must be files
	 *
	 * @param queryFile
	 * @param expectedResultFile
	 * @param checkOrder
	 * @param doubleCheckClose   double check that closing works as intended even if no results are retrieved
	 * @throws Exception
	 */
	protected void execute(String queryFile, String expectedResultFile, boolean checkOrder, boolean doubleCheckClose)
			throws Exception {

		String queryString = readQueryString(queryFile);

		Query query = queryManager().prepareQuery(queryString);

		if (query instanceof TupleQuery) {
			// Some query results will automatically close themselves when they are exhausted. To properly test that
			// query results are closed correctly we need to evaluate the query without retrieving any elements.
			if (doubleCheckClose) {
				try (TupleQueryResult evaluate = ((TupleQuery) query).evaluate()) {
					// do nothing
				}
			}

			try (TupleQueryResult queryResult = ((TupleQuery) query).evaluate()) {
				try (TupleQueryResult expectedResult = readExpectedTupleQueryResult(expectedResultFile)) {
					compareTupleQueryResults(queryResult, expectedResult, checkOrder);
				}
			}

		} else if (query instanceof GraphQuery) {
			// Some query results will automatically close themselves when they are exhausted. To properly test that
			// query results are closed correctly we need to evaluate the query without retrieving any elements.
			if (doubleCheckClose) {
				((GraphQuery) query).evaluate().close();
			}

			try (GraphQueryResult gqr = ((GraphQuery) query).evaluate()) {
				Set<Statement> queryResult = Iterations.asSet(gqr);

				Set<Statement> expectedResult = readExpectedGraphQueryResult(expectedResultFile);

				compareGraphs(queryResult, expectedResult);
			}

		} else if (query instanceof BooleanQuery) {
			boolean queryResult = ((BooleanQuery) query).evaluate();
			boolean expectedResult = readExpectedBooleanQueryResult(expectedResultFile);
			Assertions.assertEquals(expectedResult, queryResult);
		} else {
			throw new RuntimeException("Unexpected query type: " + query.getClass());
		}
	}

	protected void evaluateQueryPlan(String queryFile, String expectedPlanFile) throws Exception {

		String actualQueryPlan = federationContext().getQueryManager().getQueryPlan(readQueryString(queryFile));
		String expectedQueryPlan = readResourceAsString(expectedPlanFile);
		assertQueryPlanEquals(expectedQueryPlan, actualQueryPlan);
	}

	protected void assertQueryPlanEquals(String expectedQueryPlan, String actualQueryPlan) {

		// make sure the comparison works cross operating system
		expectedQueryPlan = expectedQueryPlan.replace("\r\n", "\n");
		actualQueryPlan = actualQueryPlan.replace("\r\n", "\n");

		actualQueryPlan = actualQueryPlan.replace("sparql_localhost:18080_repositories_", "");
		actualQueryPlan = actualQueryPlan.replace("remote_", "");
		Assertions.assertEquals(expectedQueryPlan, actualQueryPlan);
	}

	protected void assertContainsAll(List<BindingSet> res, String bindingName, Set<Value> expected) {
		Assertions.assertEquals(expected,
				res.stream().map(bs -> bs.getValue(bindingName)).collect(Collectors.toSet()));
		Assertions.assertEquals(expected.size(), res.size());
	}

	protected Literal l(String value) {
		return SimpleValueFactory.getInstance().createLiteral(value);
	}

	/**
	 * @param localName
	 * @return the IRI in the instance's {@link #defaultNamespace}
	 */
	protected IRI iri(String localName) {
		return iri(defaultNamespace, localName);
	}

	protected static IRI iri(String namespace, String localName) {
		return vf.createIRI(namespace, localName);
	}

	/**
	 * Read the query string from the specified resource
	 *
	 * @param queryFile
	 * @return
	 * @throws RepositoryException
	 * @throws IOException
	 */
	protected String readQueryString(String queryFile) throws RepositoryException, IOException {
		return readResourceAsString(queryFile);
	}

	/**
	 * Read resource from classpath as string, e.g. /tests/basic/data01endpoint1.ttl
	 *
	 * @param resource
	 * @return
	 * @throws IOException
	 */
	protected String readResourceAsString(String resource) throws IOException {
		try (InputStream stream = FedXBaseTest.class.getResourceAsStream(resource)) {
			assert stream != null;
			return IOUtil.readString(new InputStreamReader(stream, StandardCharsets.UTF_8));
		}
	}

	/**
	 * Read the expected tuple query result from the specified resource
	 *
	 * @param resultFile
	 * @return
	 * @throws RepositoryException
	 * @throws IOException
	 */
	protected TupleQueryResult readExpectedTupleQueryResult(String resultFile) throws Exception {
		QueryResultFormat tqrFormat = QueryResultIO.getParserFormatForFileName(resultFile).orElseThrow();

		InputStream in = SPARQLBaseTest.class.getResourceAsStream(resultFile);

		try (in) {
			if (in == null) {
				throw new IOException("File could not be opened: " + resultFile);
			}
			TupleQueryResultParser parser = QueryResultIO.createTupleParser(tqrFormat);

			TupleQueryResultBuilder qrBuilder = new TupleQueryResultBuilder();
			parser.setQueryResultHandler(qrBuilder);

			parser.parseQueryResult(in);
			return qrBuilder.getQueryResult();
		}
	}

	/**
	 * Read the expected graph query result from the specified resource
	 *
	 * @param resultFile
	 * @return
	 * @throws Exception
	 */
	protected Set<Statement> readExpectedGraphQueryResult(String resultFile) throws Exception {
		RDFFormat rdfFormat = Rio.getParserFormatForFileName(resultFile).orElseThrow();

		RDFParser parser = Rio.createParser(rdfFormat);
		parser.setPreserveBNodeIDs(true);
		parser.setValueFactory(SimpleValueFactory.getInstance());

		Set<Statement> result = new LinkedHashSet<>();
		parser.setRDFHandler(new StatementCollector(result));

		try (InputStream in = SPARQLBaseTest.class.getResourceAsStream(resultFile)) {
			parser.parse(in, resultFile);
		}

		return result;
	}

	protected boolean readExpectedBooleanQueryResult(String resultFile) throws Exception {
		QueryResultFormat bqrFormat = BooleanQueryResultParserRegistry.getInstance()
				.getFileFormatForFileName(
						resultFile)
				.orElseThrow();

		try (InputStream in = SPARQLBaseTest.class.getResourceAsStream(resultFile)) {
			return QueryResultIO.parseBoolean(in, bqrFormat);
		}
	}

	protected SimpleTupleQueryResultBuilder tupleQueryResultBuilder(List<String> bindingNames) {
		return new SimpleTupleQueryResultBuilder(bindingNames);
	}

	/**
	 * Note: metod can only be used after initialization phase
	 *
	 * @return the current {@link FederationContext}
	 */
	protected abstract FederationContext federationContext();

	protected QueryManager queryManager() {
		return federationContext().getQueryManager();
	}

	/**
	 * Compare two tuple query results
	 *
	 * @param queryResult
	 * @param expectedResult
	 * @param checkOrder
	 */
	protected void compareTupleQueryResults(TupleQueryResult queryResult, TupleQueryResult expectedResult,
			boolean checkOrder) {
		// Create MutableTupleQueryResult to be able to re-iterate over the
		// results

		MutableTupleQueryResult queryResultTable = new MutableTupleQueryResult(queryResult);
		MutableTupleQueryResult expectedResultTable = new MutableTupleQueryResult(expectedResult);

		boolean resultsEqual;

		resultsEqual = QueryResults.equals(queryResultTable, expectedResultTable);

		if (checkOrder) {
			// also check the order in which solutions occur.
			queryResultTable.beforeFirst();
			expectedResultTable.beforeFirst();

			while (queryResultTable.hasNext()) {
				BindingSet bs = queryResultTable.next();
				BindingSet expectedBs = expectedResultTable.next();

				if (!bs.equals(expectedBs)) {
					resultsEqual = false;
					break;
				}
			}
		}

		if (!resultsEqual) {
			queryResultTable.beforeFirst();
			expectedResultTable.beforeFirst();

			List<BindingSet> queryBindings = Iterations.asList(queryResultTable);

			List<BindingSet> expectedBindings = Iterations.asList(expectedResultTable);

			List<BindingSet> missingBindings = new ArrayList<>(expectedBindings);
			missingBindings.removeAll(queryBindings);

			List<BindingSet> unexpectedBindings = new ArrayList<>(queryBindings);
			unexpectedBindings.removeAll(expectedBindings);

			StringBuilder message = new StringBuilder(128);

			if (!missingBindings.isEmpty()) {

				message.append("Missing bindings: \n");
				for (BindingSet bs : missingBindings) {
					message.append(bs);
					message.append("\n");
				}
			}

			if (!unexpectedBindings.isEmpty()) {
				message.append("Unexpected bindings: \n");
				for (BindingSet bs : unexpectedBindings) {
					message.append(bs);
					message.append("\n");
				}
			}

			if (checkOrder && missingBindings.isEmpty() && unexpectedBindings.isEmpty()) {
				message.append("Results are not in expected order.\n");
				message.append(" =======================\n");
				message.append("query result: \n");
				for (BindingSet bs : queryBindings) {
					message.append(bs);
					message.append("\n");
				}
				message.append(" =======================\n");
				message.append("expected result: \n");
				for (BindingSet bs : expectedBindings) {
					message.append(bs);
					message.append("\n");
				}
				message.append(" =======================\n");

				System.out.print(message);
			}

			log.error(message.toString());
			Assertions.fail(message.toString());
		}

	}

	/**
	 * Compare two graphs
	 *
	 * @param queryResult
	 * @param expectedResult
	 */
	protected void compareGraphs(Set<Statement> queryResult, Set<Statement> expectedResult) {
		if (!Models.isomorphic(expectedResult, queryResult)) {
			StringBuilder message = new StringBuilder(128);
			message.append("Expected result: \n");
			for (Statement st : expectedResult) {
				message.append(st.toString());
				message.append("\n");
			}

			message.append("Query result: \n");
			for (Statement st : queryResult) {
				message.append(st.toString());
				message.append("\n");
			}

			log.error(message.toString());
			Assertions.fail(message.toString());
		}
	}

	/**
	 * A builder for {@link TupleQueryResult}s.
	 *
	 * @author as
	 */
	public static class SimpleTupleQueryResultBuilder {

		private final List<String> bindingNames;
		private final List<BindingSet> bindings = Lists.newArrayList();

		private SimpleTupleQueryResultBuilder(List<String> bindingNames) {
			this.bindingNames = bindingNames;
		}

		/**
		 * Add the {@link BindingSet} to the result.
		 *
		 * @param b
		 * @return
		 * @throws IllegalArgumentException if the provided binding names is not a subset of the defined result binding
		 *                                  names
		 */
		public SimpleTupleQueryResultBuilder add(BindingSet b) throws IllegalArgumentException {

			// check if the binding names are a subset of defined binding names
			if (!bindingNames.containsAll(b.getBindingNames())) {
				throw new IllegalArgumentException(
						"Provided binding set does must be a subset of defined binding names: " + bindingNames
								+ ". Was: " + b.getBindingNames());
			}
			this.bindings.add(b);
			return this;
		}

		public SimpleTupleQueryResultBuilder add(List<? extends Value> values) {
			if (values.size() != bindingNames.size()) {
				throw new IllegalArgumentException("Values for each binding name required.");
			}
			BindingSet b = new ListBindingSet(bindingNames, values);
			return add(b);
		}

		@SuppressWarnings("unchecked")
		public SimpleTupleQueryResultBuilder add(List<? extends Value>... rows) {
			for (List<? extends Value> values : rows) {
				add(values);
			}
			return this;
		}

		public TupleQueryResult build() {
			return new MutableTupleQueryResult(bindingNames, bindings);
		}
	}

}