QueryStorage.java

/*******************************************************************************
 * Copyright (c) 2015 Eclipse RDF4J contributors, Aduna, and others.
 *
 * 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.workbench.util;

import java.io.File;
import java.util.UUID;

import org.eclipse.rdf4j.common.app.AppConfiguration;
import org.eclipse.rdf4j.common.exception.RDF4JException;
import org.eclipse.rdf4j.model.IRI;
import org.eclipse.rdf4j.query.MalformedQueryException;
import org.eclipse.rdf4j.query.QueryEvaluationException;
import org.eclipse.rdf4j.query.QueryLanguage;
import org.eclipse.rdf4j.query.QueryResultHandlerException;
import org.eclipse.rdf4j.query.TupleQuery;
import org.eclipse.rdf4j.query.TupleQueryResult;
import org.eclipse.rdf4j.query.UpdateExecutionException;
import org.eclipse.rdf4j.repository.Repository;
import org.eclipse.rdf4j.repository.RepositoryConnection;
import org.eclipse.rdf4j.repository.RepositoryException;
import org.eclipse.rdf4j.repository.http.HTTPRepository;
import org.eclipse.rdf4j.repository.sail.SailRepository;
import org.eclipse.rdf4j.sail.nativerdf.NativeStore;
import org.eclipse.rdf4j.workbench.exceptions.BadRequestException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * Provides an interface to the private repository with the saved queries.
 *
 * @author Dale Visser
 */
public class QueryStorage {

	private static final Object LOCK = new Object();

	private static final QueryEvaluator EVAL = QueryEvaluator.INSTANCE;

	private static QueryStorage instance;

	public static QueryStorage getSingletonInstance(final AppConfiguration config)
			throws RepositoryException {
		synchronized (LOCK) {
			if (instance == null || instance.isShutdown()) {
				instance = new QueryStorage(config);
			}
			return instance;
		}
	}

	private boolean isShutdown() {
		return queries == null || !queries.isInitialized();
	}

	private static final Logger LOGGER = LoggerFactory.getLogger(QueryStorage.class);

	private static final String PRE = "PREFIX : <https://openrdf.org/workbench/>\n";

	// SAVE needs xsd: prefix since explicit XSD data types will be substituted.
	private static final String SAVE = "PREFIX xsd:<http://www.w3.org/2001/XMLSchema#>\n" + PRE
			+ "INSERT DATA { $<query> :userName $<userName> ; :queryName $<queryName> ; "
			+ ":repository $<repository> ; :shared $<shared> ; :queryLanguage $<queryLanguage> ; :query $<queryText> ; "
			+ ":infer $<infer> ; :rowsPerPage $<rowsPerPage> . }";

	private static final String ASK_EXISTS = PRE
			+ "ASK { [] :userName $<userName> ; :queryName $<queryName> ; :repository $<repository> . }";

	private static final String UPDATE_FILTER = "FILTER (?user = $<userName> || ?user = \"\" ) } ";

	private static final String READ_FILTER = "FILTER (?user = $<userName> || ?user = \"\" || ?shared) } ";

	private static final String ASK_UPDATABLE = PRE + "ASK { $<query> :userName ?user . " + UPDATE_FILTER;

	private static final String ASK_READABLE = PRE + "ASK { $<query> :userName ?user  ; :shared ?shared . "
			+ READ_FILTER;

	private static final String DELETE = PRE + "DELETE WHERE { $<query> :userName ?user ; ?p ?o . }";

	private static final String MATCH = ":shared ?s ; :queryLanguage ?ql ; :query ?q ; :rowsPerPage ?rpp .\n";

	private static final String UPDATE = PRE + "DELETE { $<query> " + MATCH
			+ "}\nINSERT { $<query> :shared $<shared> ; :queryLanguage $<queryLanguage> ; :query $<queryText> ; "
			+ ":infer $<infer> ; :rowsPerPage $<rowsPerPage> . } WHERE { $<query> :userName ?user ; " + MATCH
			+ UPDATE_FILTER;

	private static final String SELECT_URI = PRE
			+ "SELECT ?query { ?query :repository $<repository> ; :userName $<userName> ; :queryName $<queryName> . } ";

	private static final String SELECT_TEXT = PRE
			+ "SELECT ?queryText { [] :repository $<repository> ; :userName $<userName> ; :queryName $<queryName> ; :query ?queryText . } ";

	private static final String SELECT = PRE
			+ "SELECT ?query ?user ?queryName ?shared ?queryLn ?queryText ?infer ?rowsPerPage "
			+ "{ ?query :repository $<repository> ; :userName ?user ; :queryName ?queryName ; :shared ?shared ; "
			+ ":queryLanguage ?queryLn ; :query ?queryText ; :infer ?infer ; :rowsPerPage ?rowsPerPage .\n"
			+ READ_FILTER + "ORDER BY ?user ?queryName";

	private final Repository queries;

	private static final String USER_NAME = "$<userName>";

	private static final String REPOSITORY = "$<repository>";

	private static final String QUERY = "$<query>";

	private static final String QUERY_NAME = "$<queryName>";

	/**
	 * Create a new object for accessing the store of user queries.
	 *
	 * @param appConfig the application configuration, for obtaining the data directory
	 * @throws RepositoryException if there is an issue creating the object to access the repository
	 */
	private QueryStorage(final AppConfiguration appConfig) throws RepositoryException {
		queries = new SailRepository(new NativeStore(new File(appConfig.getDataDir(), "queries")));
		queries.init();
	}

	public void shutdown() {
		try {
			if (queries != null && queries.isInitialized()) {
				queries.shutDown();
			}
		} catch (RepositoryException e) {
			LOGGER.warn(e.getMessage());
		}
	}

	/**
	 * Checks whether the current user/password credentials can really access the current repository.
	 *
	 * @param repository the current repository
	 * @return true, if it is possible to request a statement from the repository with the given credentials
	 * @throws RepositoryException if there is an issue closing the connection
	 */
	public boolean checkAccess(final HTTPRepository repository) throws RepositoryException {
		LOGGER.info("repository: {}", repository.getRepositoryURL());
		boolean rval = true;
		try (RepositoryConnection con = repository.getConnection()) {
			// Manufacture an unlikely unique statement to check.
			IRI uri = con.getValueFactory().createIRI("urn:uuid:" + UUID.randomUUID());
			con.hasStatement(uri, uri, uri, false, uri);
		} catch (RepositoryException re) {
			rval = false;
		}
		return rval;
	}

	/**
	 * Save a query. UNSAFE from an injection point of view. It is the responsibility of the calling code to call
	 * checkAccess() with the full credentials first.
	 *
	 * @param repository    the repository the query is associated with
	 * @param queryName     the name for the query
	 * @param userName      the user saving the query
	 * @param shared        whether the query is to be shared with other users
	 * @param queryLanguage the language of the query (only SPARQL is currently supported)
	 * @param queryText     the actual query text
	 * @param infer
	 * @param rowsPerPage   rows to display per page, may be 0 (all), 10, 50, 100, or 200)
	 * @throws RDF4JException
	 */
	public void saveQuery(final HTTPRepository repository, final String queryName, final String userName,
			final boolean shared, final QueryLanguage queryLanguage, final String queryText, final boolean infer,
			final int rowsPerPage) throws RDF4JException {
		if (QueryLanguage.SPARQL != queryLanguage) {
			throw new RepositoryException("May only save SPARQL queries, not" + queryLanguage.toString());
		}
		if (0 != rowsPerPage && 10 != rowsPerPage && 20 != rowsPerPage && 50 != rowsPerPage && 100 != rowsPerPage
				&& 200 != rowsPerPage) {
			throw new RepositoryException("Illegal value for rows per page: " + rowsPerPage);
		}
		this.checkQueryText(queryText);
		final QueryStringBuilder save = new QueryStringBuilder(SAVE);
		save.replaceURI(REPOSITORY, repository.getRepositoryURL());
		save.replaceURI(QUERY, "urn:uuid:" + UUID.randomUUID());
		save.replaceQuote(QUERY_NAME, queryName);
		this.replaceUpdateFields(save, userName, shared, queryLanguage, queryText, infer, rowsPerPage);
		updateQueryRepository(save.toString());
	}

	/**
	 * Determines whether the user with the given userName is allowed to update or delete the given query.
	 *
	 * @param query       the node identifying the query of interest
	 * @param currentUser the user to check access for
	 * @return <var>true</var> if the given query was saved by the given user or the anonymous user
	 */
	public boolean canChange(final IRI query, final String currentUser)
			throws RepositoryException, QueryEvaluationException, MalformedQueryException {
		return performAccessQuery(ASK_UPDATABLE, query, currentUser);
	}

	/**
	 * Determines whether the user with the given userName is allowed to read the given query.
	 *
	 * @param query       the node identifying the query of interest
	 * @param currentUser the user to check access for
	 * @return <var>true</var> if the given query was saved by either the given user or the anonymous user, or is shared
	 */
	public boolean canRead(IRI query, String currentUser)
			throws RepositoryException, QueryEvaluationException, MalformedQueryException {
		return performAccessQuery(ASK_READABLE, query, currentUser);
	}

	private boolean performAccessQuery(String accessSPARQL, IRI query, String currentUser)
			throws RepositoryException, QueryEvaluationException, MalformedQueryException {
		final QueryStringBuilder canDelete = new QueryStringBuilder(accessSPARQL);
		canDelete.replaceURI(QUERY, query.stringValue());
		canDelete.replaceQuote(USER_NAME, currentUser);
		LOGGER.info("{}", canDelete);
		try (RepositoryConnection connection = this.queries.getConnection()) {
			return connection.prepareBooleanQuery(QueryLanguage.SPARQL, canDelete.toString()).evaluate();
		}
	}

	public boolean askExists(final HTTPRepository repository, final String queryName, final String userName)
			throws QueryEvaluationException, RepositoryException, MalformedQueryException {
		final QueryStringBuilder ask = new QueryStringBuilder(ASK_EXISTS);
		ask.replaceURI(REPOSITORY, repository.getRepositoryURL());
		ask.replaceQuote(QUERY_NAME, queryName);
		ask.replaceQuote(USER_NAME, userName);
		LOGGER.info("{}", ask);
		try (RepositoryConnection connection = this.queries.getConnection()) {
			return connection.prepareBooleanQuery(QueryLanguage.SPARQL, ask.toString()).evaluate();
		}
	}

	/**
	 * Delete the given query for the given user. It is the responsibility of the calling code to call checkAccess() and
	 * canDelete() with the full credentials first.
	 *
	 * @param query
	 * @param userName
	 * @throws RepositoryException
	 * @throws UpdateExecutionException
	 * @throws MalformedQueryException
	 */
	public void deleteQuery(final IRI query, final String userName)
			throws RepositoryException, UpdateExecutionException, MalformedQueryException {
		final QueryStringBuilder delete = new QueryStringBuilder(DELETE);
		delete.replaceQuote(QueryStorage.USER_NAME, userName);
		delete.replaceURI(QUERY, query.stringValue());
		updateQueryRepository(delete.toString());
	}

	/**
	 * Update the entry for the given query. It is the responsibility of the calling code to call checkAccess() with the
	 * full credentials first.
	 *
	 * @param query         the query to update
	 * @param userName      the user name
	 * @param shared        whether to share with other users
	 * @param queryLanguage the query language
	 * @param queryText     the text of the query
	 * @param infer
	 * @param rowsPerPage   the rows per page to display of the query
	 * @throws RepositoryException      if a problem occurs during the update
	 * @throws UpdateExecutionException if a problem occurs during the update
	 * @throws MalformedQueryException  if a problem occurs during the update
	 */
	public void updateQuery(final IRI query, final String userName, final boolean shared,
			final QueryLanguage queryLanguage, final String queryText, final boolean infer, final int rowsPerPage)
			throws RepositoryException, UpdateExecutionException, MalformedQueryException {
		final QueryStringBuilder update = new QueryStringBuilder(UPDATE);
		update.replaceURI(QUERY, query);
		this.replaceUpdateFields(update, userName, shared, queryLanguage, queryText, infer, rowsPerPage);
		this.updateQueryRepository(update.toString());
	}

	/**
	 * Prepares a query to retrieve the queries accessible to the given user in the given repository. When evaluated,
	 * the query result will have the following binding names: query, user, queryName, shared, queryLn, queryText,
	 * rowsPerPage. It is the responsibility of the calling code to call checkAccess() with the full credentials first.
	 *
	 * @param repository that the saved queries run against
	 * @param userName   that is requesting the saved queries
	 * @param builder    receives a list of all the saved queries against the given repository and accessible to the
	 *                   given user
	 * @throws RepositoryException         if there's a problem connecting to the saved queries repository
	 * @throws MalformedQueryException     if the query is not legal SPARQL
	 * @throws QueryEvaluationException    if there is a problem while attempting to evaluate the query
	 * @throws QueryResultHandlerException
	 */
	public void selectSavedQueries(final HTTPRepository repository, final String userName,
			final TupleResultBuilder builder)
			throws RepositoryException, MalformedQueryException, QueryEvaluationException, QueryResultHandlerException {
		final QueryStringBuilder select = new QueryStringBuilder(SELECT);
		select.replaceQuote(USER_NAME, userName);
		select.replaceURI(REPOSITORY, repository.getRepositoryURL());
		try (RepositoryConnection connection = this.queries.getConnection()) {
			EVAL.evaluateTupleQuery(builder, connection.prepareTupleQuery(QueryLanguage.SPARQL, select.toString()));
		}
	}

	/**
	 * Returns the URI for the saved query in the given repository with the given name, owned by the given owner.
	 *
	 * @param repository The repository the query is associated with.
	 * @param owner      The user that saved the query.
	 * @param queryName  The name given to the query.
	 * @return if it exists, the URI referring to the specified saved query.
	 * @throws RDF4JException      if issues occur performing the necessary queries.
	 * @throws BadRequestException if the the specified stored query doesn't exist
	 */
	public IRI selectSavedQuery(final HTTPRepository repository, final String owner, final String queryName)
			throws RDF4JException, BadRequestException {
		final QueryStringBuilder select = new QueryStringBuilder(SELECT_URI);
		select.replaceQuote(QueryStorage.USER_NAME, owner);
		select.replaceURI(REPOSITORY, repository.getRepositoryURL());
		select.replaceQuote(QUERY_NAME, queryName);
		try (RepositoryConnection connection = this.queries.getConnection()) {
			TupleQuery query = connection.prepareTupleQuery(QueryLanguage.SPARQL, select.toString());
			try (TupleQueryResult result = query.evaluate()) {
				if (result.hasNext()) {
					return (IRI) (result.next().getValue("query"));
				} else {
					throw new BadRequestException("Could not find query entry in storage.");
				}
			}

		}
	}

	/**
	 * Retrieves the specified query text. No security checks are done here. If the saved query exists, its text is
	 * returned.
	 *
	 * @param repository Repository that the saved query is associated with.
	 * @param owner      The user that saved the query.
	 * @param queryName  The name given to the saved query.
	 * @return the text of the saved query, if it exists
	 * @throws RDF4JException      if a problem occurs accessing storage
	 * @throws BadRequestException if the specified query doesn't exist
	 */
	public String getQueryText(final HTTPRepository repository, final String owner, final String queryName)
			throws RDF4JException, BadRequestException {
		final QueryStringBuilder select = new QueryStringBuilder(SELECT_TEXT);
		select.replaceQuote(QueryStorage.USER_NAME, owner);
		select.replaceURI(REPOSITORY, repository.getRepositoryURL());
		select.replaceQuote(QUERY_NAME, queryName);
		try (RepositoryConnection connection = this.queries.getConnection()) {
			TupleQuery query = connection.prepareTupleQuery(QueryLanguage.SPARQL, select.toString());
			try (TupleQueryResult result = query.evaluate()) {
				if (result.hasNext()) {
					return result.next().getValue("queryText").stringValue();
				} else {
					throw new BadRequestException("Could not find query entry in storage.");
				}
			}

		}
	}

	private void updateQueryRepository(final String update)
			throws RepositoryException, UpdateExecutionException, MalformedQueryException {
		LOGGER.info("SPARQL/Update of Query Storage:\n--\n{}\n--", update);
		try (RepositoryConnection connection = this.queries.getConnection()) {
			connection.prepareUpdate(QueryLanguage.SPARQL, update).execute();
		}
	}

	/**
	 * Perform replacement on several common fields for update operations.
	 *
	 * @param userName      the name of the current user
	 * @param shared        whether the saved query is to be shared with other users
	 * @param queryLanguage the language of the saved query
	 * @param queryText     the actual text of the query to save
	 * @param infer
	 * @param rowsPerPage   the rows per page to display for results
	 */
	private void replaceUpdateFields(final QueryStringBuilder builder, final String userName, final boolean shared,
			final QueryLanguage queryLanguage, final String queryText, final boolean infer, final int rowsPerPage) {
		builder.replaceQuote(USER_NAME, userName);
		builder.replace("$<shared>", QueryStringBuilder.xsdQuote(String.valueOf(shared), "boolean"));
		builder.replaceQuote("$<queryLanguage>", queryLanguage.toString());
		checkQueryText(queryText);
		builder.replace("$<queryText>", QueryStringBuilder.quote(queryText, "'''", "'''"));
		builder.replace("$<infer>", QueryStringBuilder.xsdQuote(String.valueOf(infer), "boolean"));
		builder.replace("$<rowsPerPage>", QueryStringBuilder.xsdQuote(String.valueOf(rowsPerPage), "unsignedByte"));
	}

	/**
	 * Imposes the rule that the query may not contain '''-quoted string, since that is how we'll be quoting it in our
	 * SPARQL/Update statements. Quoting the query with ''' assuming all string literals in the query are of the
	 * STRING_LITERAL1, STRING_LITERAL2 or STRING_LITERAL_LONG2 types.
	 *
	 * @param queryText the query text
	 */
	private void checkQueryText(final String queryText) {
		if (queryText.indexOf("'''") > 0) {
			throw new IllegalArgumentException("queryText may not contain '''-quoted strings.");
		}
	}
}