RepositoryManager.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.repository.manager;

import java.net.MalformedURLException;
import java.net.URL;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Objects;
import java.util.Set;

import org.apache.http.client.HttpClient;
import org.eclipse.rdf4j.http.client.HttpClientDependent;
import org.eclipse.rdf4j.model.IRI;
import org.eclipse.rdf4j.model.Model;
import org.eclipse.rdf4j.model.ModelFactory;
import org.eclipse.rdf4j.model.impl.LinkedHashModel;
import org.eclipse.rdf4j.model.impl.SimpleValueFactory;
import org.eclipse.rdf4j.model.impl.TreeModelFactory;
import org.eclipse.rdf4j.model.util.Values;
import org.eclipse.rdf4j.model.vocabulary.CONFIG;
import org.eclipse.rdf4j.repository.Repository;
import org.eclipse.rdf4j.repository.RepositoryException;
import org.eclipse.rdf4j.repository.RepositoryResolver;
import org.eclipse.rdf4j.repository.config.RepositoryConfig;
import org.eclipse.rdf4j.repository.config.RepositoryConfigException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * A manager for {@link Repository}s.
 *
 * @author Arjohn Kampman
 * @see RepositoryProvider
 */
public abstract class RepositoryManager implements RepositoryResolver, HttpClientDependent {

	/*-----------*
	 * Constants *
	 *-----------*/

	protected final Logger logger = LoggerFactory.getLogger(this.getClass());

	/**
	 * The {@link org.eclipse.rdf4j.repository.sail.ProxyRepository} schema namespace (
	 * <var>http://www.openrdf.org/config/repository/proxy#</var>).
	 *
	 * @deprecated use {@link CONFIG.Proxy} instead.
	 */
	public static final String NAMESPACE = "http://www.openrdf.org/config/repository/proxy#";

	/**
	 * <var>http://www.openrdf.org/config/repository/proxy#proxiedID</var>
	 *
	 * @deprecated use {@link CONFIG.Proxy#proxiedID} instead.
	 */
	@Deprecated(since = "4.3.0", forRemoval = true)
	public final static IRI PROXIED_ID = SimpleValueFactory.getInstance().createIRI(NAMESPACE, "proxiedID");

	/*-----------*
	 * Variables *
	 *-----------*/

	private ModelFactory modelFactory = new TreeModelFactory();

	protected Map<String, Repository> initializedRepositories;

	private boolean initialized;

	/*--------------*
	 * Constructors *
	 *--------------*/

	/**
	 * Creates a new RepositoryManager.
	 */
	protected RepositoryManager() {
		this(new HashMap<>());
	}

	/**
	 * Create a new RepositoryManager using the given map to store repository information.
	 *
	 * @param initializedRepositories A map that will be used to store repository information.
	 */
	protected RepositoryManager(Map<String, Repository> initializedRepositories) {
		setInitializedRepositories(initializedRepositories);
	}

	/*---------*
	 * Methods *
	 *---------*/

	/**
	 * Indicates if this RepositoryManager has been initialized. Note that the initialization status may change if the
	 * Repository is shut down.
	 *
	 * @return true iff the repository manager has been initialized.
	 */
	public boolean isInitialized() {
		return initialized;
	}

	/**
	 * @return Returns the httpClient passed to {@link Repository} construction.
	 */
	@Override
	public abstract HttpClient getHttpClient();

	/**
	 * Should be called before {@link #init()}.
	 *
	 * @param httpClient The httpClient to use for remote/service calls.
	 */
	@Override
	public abstract void setHttpClient(HttpClient httpClient);

	/**
	 * Get the {@link ModelFactory} used for creating new {@link Model} objects in the manager.
	 *
	 * @return the modelFactory
	 * @since 3.0
	 */
	public ModelFactory getModelFactory() {
		return modelFactory;
	}

	/**
	 * Set the {@link ModelFactory} to use for creating new {@link Model} objects in the manager.
	 *
	 * @param modelFactory the modelFactory to set. May not be <code>null</code>.
	 * @since 3.0
	 */
	public void setModelFactory(ModelFactory modelFactory) {
		Objects.requireNonNull(modelFactory);
		this.modelFactory = modelFactory;
	}

	/**
	 * Initializes the repository manager.
	 *
	 * @throws RepositoryException If the manager failed to initialize.
	 * @since 2.5
	 */
	public void init() throws RepositoryException {
		initialized = true;
	}

	/**
	 * Generates an ID for a new repository based on the specified base name. The base name may for example be a
	 * repository name entered by the user. The generated ID will contain a variant of this name that does not occur as
	 * a repository ID in this manager yet and is suitable for use as a file name (e.g. for the repository's data
	 * directory).
	 *
	 * @param baseName The String on which the returned ID should be based, must not be <var>null</var>.
	 * @return A new repository ID derived from the specified base name.
	 * @throws RepositoryException
	 * @throws RepositoryConfigException
	 */
	public String getNewRepositoryID(String baseName) throws RepositoryException, RepositoryConfigException {
		if (baseName != null) {
			// Filter exotic characters from the base name
			baseName = baseName.trim();

			int length = baseName.length();
			StringBuilder buffer = new StringBuilder(length);

			for (char c : baseName.toCharArray()) {
				if (Character.isLetter(c) || Character.isDigit(c) || c == '-' || c == '_' || c == '.') {
					// Convert to lower case since file names are case insensitive on
					// some/most platforms
					buffer.append(Character.toLowerCase(c));
				} else if (c != '"' && c != '\'') {
					buffer.append('-');
				}
			}

			baseName = buffer.toString();
		}

		// First try if we can use the base name without an appended index
		if (baseName != null && !baseName.isEmpty() && !hasRepositoryConfig(baseName)) {
			return baseName;
		}

		// When the base name is null or empty, generate one
		if (baseName == null || baseName.isEmpty()) {
			baseName = "repository-";
		} else if (!baseName.endsWith("-")) {
			baseName += "-";
		}

		// Keep appending numbers until we find an unused ID
		int index = 2;
		while (hasRepositoryConfig(baseName + index)) {
			index++;
		}

		return baseName + index;
	}

	/**
	 * Get the IDs of all available repositories. Note that this is potentially slow as it may initialize all available
	 * repository configurations.
	 *
	 * @return a list of repository ID strings.
	 * @throws RepositoryException
	 * @see {@link #getInitializedRepositoryIDs()}
	 */
	public Set<String> getRepositoryIDs() throws RepositoryException {
		Set<String> idSet = new LinkedHashSet<>();
		getAllRepositoryInfos().forEach(info -> {
			idSet.add(info.getId());
		});
		return idSet;
	}

	public boolean hasRepositoryConfig(String repositoryID) throws RepositoryException, RepositoryConfigException {
		return getRepositoryInfo(repositoryID) != null;
	}

	public abstract RepositoryConfig getRepositoryConfig(String repositoryID)
			throws RepositoryConfigException, RepositoryException;

	/**
	 * Adds or updates the configuration of a repository to the manager. The manager may already contain a configuration
	 * for a repository with the same ID as specified by <var>config</var>, in which case all previous configuration
	 * data for that repository will be cleared before the new configuration is added.
	 *
	 * @param config The repository configuration that should be added to or updated in the manager.
	 * @throws RepositoryException       If the manager failed to update.
	 * @throws RepositoryConfigException If the manager doesn't know how to update a configuration due to inconsistent
	 *                                   configuration data. For example, this happens when there are multiple existing
	 *                                   configurations with the concerning ID.
	 */
	public abstract void addRepositoryConfig(RepositoryConfig config)
			throws RepositoryException, RepositoryConfigException;

	/**
	 * Checks on whether the given repository is referred to by a
	 * {@link org.eclipse.rdf4j.repository.sail.ProxyRepository} configuration.
	 *
	 * @param repositoryID id to check
	 * @return true if there is no existing proxy reference to the given id, false otherwise
	 * @throws RepositoryException
	 */
	public boolean isSafeToRemove(String repositoryID) throws RepositoryException {
		for (String id : getRepositoryIDs()) {
			RepositoryConfig config = getRepositoryConfig(id);
			Model model = new LinkedHashModel();
			config.export(model, Values.bnode());
			if (model.contains(null, CONFIG.Proxy.proxiedID, Values.literal(repositoryID))
					|| model.contains(null, PROXIED_ID, Values.literal(repositoryID))) {
				return false;
			}
		}
		return true;
	}

	/**
	 * Removes the specified repository by deleting its configuration if such a configuration is present, and removing
	 * any persistent data associated with the repository. Returns <var>true</var> if the specified repository
	 * configuration was actually present. <strong>NB this operation can not be undone!</strong>
	 *
	 * @param repositoryID The ID of the repository that needs to be removed.
	 * @throws RepositoryException       If the manager failed to update the configuration.
	 * @throws RepositoryConfigException If the manager doesn't know how to remove a repository due to inconsistent
	 *                                   configuration data. For example, this can happen when there are multiple
	 *                                   existing configurations with the concerning ID.
	 */
	public boolean removeRepository(String repositoryID) throws RepositoryException, RepositoryConfigException {
		logger.debug("Removing repository {}.", repositoryID);
		boolean isRemoved = hasRepositoryConfig(repositoryID);

		synchronized (initializedRepositories) {
			if (isRemoved) {
				logger.debug("Shutdown repository {} after removal of configuration.", repositoryID);
				Repository repository = initializedRepositories.remove(repositoryID);

				if (repository != null && repository.isInitialized()) {
					repository.shutDown();
				}
			}
		}

		return isRemoved;
	}

	/**
	 * Gets the repository that is known by the specified ID from this manager.
	 *
	 * @param identity A repository ID.
	 * @return An initialized Repository object, or <var>null</var> if no repository was known for the specified ID.
	 * @throws RepositoryConfigException If no repository could be created due to invalid or incomplete configuration
	 *                                   data.
	 */
	@Override
	public Repository getRepository(String identity) throws RepositoryConfigException, RepositoryException {
		synchronized (initializedRepositories) {
			updateInitializedRepositories();
			Repository result = initializedRepositories.get(identity);

			if (result != null && !result.isInitialized()) {
				// repository exists but has been shut down. throw away the old
				// object so we can re-read from the config.
				initializedRepositories.remove(identity);
				result = null;
			}

			if (result == null) {
				// First call (or old object thrown away), create and initialize the
				// repository.
				result = createRepository(identity);

				if (result != null) {
					initializedRepositories.put(identity, result);
				}
			}

			return result;
		}
	}

	/**
	 * Returns all initialized repositories. This method returns fast as no lazy creation of repositories takes place.
	 *
	 * @return a collection containing the IDs of all initialized repositories.
	 * @see #getRepositoryIDs()
	 */
	public Set<String> getInitializedRepositoryIDs() {
		synchronized (initializedRepositories) {
			updateInitializedRepositories();
			return new HashSet<>(initializedRepositories.keySet());
		}
	}

	/**
	 * Returns all initialized repositories. This method returns fast as no lazy creation of repositories takes place.
	 *
	 * @return a set containing the initialized repositories.
	 * @see #getAllRepositories()
	 */
	public Collection<Repository> getInitializedRepositories() {
		synchronized (initializedRepositories) {
			updateInitializedRepositories();
			return new ArrayList<>(initializedRepositories.values());
		}
	}

	Repository getInitializedRepository(String repositoryID) {
		synchronized (initializedRepositories) {
			updateInitializedRepositories();
			return initializedRepositories.get(repositoryID);
		}
	}

	Repository removeInitializedRepository(String repositoryID) {
		synchronized (initializedRepositories) {
			updateInitializedRepositories();
			return initializedRepositories.remove(repositoryID);
		}
	}

	protected void setInitializedRepositories(Map<String, Repository> nextInitializedRepositories) {
		initializedRepositories = nextInitializedRepositories;
	}

	protected void updateInitializedRepositories() {
		synchronized (initializedRepositories) {
			Iterator<Repository> iter = initializedRepositories.values().iterator();
			while (iter.hasNext()) {
				Repository next = iter.next();
				if (!next.isInitialized()) {
					iter.remove();
					try {
						next.shutDown();
					} catch (RepositoryException e) {

					}
				}
			}
		}
	}

	/**
	 * Returns all configured repositories. This may be an expensive operation as it initializes repositories that have
	 * not been initialized yet.
	 *
	 * @return The Set of all configured Repositories.
	 * @see #getInitializedRepositories()
	 */
	public Collection<Repository> getAllRepositories() throws RepositoryConfigException, RepositoryException {
		Set<String> idSet = getRepositoryIDs();

		ArrayList<Repository> result = new ArrayList<>(idSet.size());

		for (String id : idSet) {
			result.add(getRepository(id));
		}

		return result;
	}

	/**
	 * Creates and initializes the repository with the specified ID.
	 *
	 * @param id A repository ID.
	 * @return The created and initialized repository, or <var>null</var> if no such repository exists.
	 * @throws RepositoryConfigException If no repository could be created due to invalid or incomplete configuration
	 *                                   data.
	 * @throws RepositoryException       If the repository could not be initialized.
	 */
	protected abstract Repository createRepository(String id) throws RepositoryConfigException, RepositoryException;

	/**
	 * Gets the repository that is known by the specified ID from this manager.
	 *
	 * @param id A repository ID.
	 * @return A Repository object, or <var>null</var> if no repository was known for the specified ID.
	 * @throws RepositoryException When not able to retrieve existing configurations
	 */
	public RepositoryInfo getRepositoryInfo(String id) throws RepositoryException {
		for (RepositoryInfo repInfo : getAllRepositoryInfos()) {
			if (repInfo.getId().equals(id)) {
				return repInfo;
			}
		}

		return null;
	}

	/**
	 * Retrieve meta information of all configured repositories.
	 *
	 * @return a collection of {@link RepositoryInfo} objects
	 * @throws RepositoryException if the repository meta information could not be retrieved.
	 */
	public abstract Collection<RepositoryInfo> getAllRepositoryInfos() throws RepositoryException;

	/**
	 * @deprecated Use {@link #getAllRepositoryInfos()} instead.
	 */
	@Deprecated(since = "4.0")
	public Collection<RepositoryInfo> getAllUserRepositoryInfos() throws RepositoryException {
		return getAllRepositoryInfos();
	}

	/**
	 * @deprecated Use {@link #getAllRepositoryInfos()} instead.
	 */
	@Deprecated(since = "4.0")
	public Collection<RepositoryInfo> getAllRepositoryInfos(boolean skipSystemRepo) throws RepositoryException {
		return getAllRepositoryInfos();
	}

	/**
	 * Shuts down all initialized user repositories.
	 *
	 * @see #shutDown()
	 */
	public void refresh() {
		logger.debug("Refreshing repository information in manager...");

		// FIXME: uninitialized, removed repositories won't be cleaned up.
		try {
			synchronized (initializedRepositories) {
				Iterator<Map.Entry<String, Repository>> iter = initializedRepositories.entrySet().iterator();

				while (iter.hasNext()) {
					Map.Entry<String, Repository> entry = iter.next();
					String repositoryID = entry.getKey();
					Repository repository = entry.getValue();

					// remove from initialized repositories
					iter.remove();
					// refresh single repository
					refreshRepository(repositoryID, repository);
				}
			}
		} catch (RepositoryException re) {
			logger.error("Failed to refresh repositories", re);
		}
	}

	/**
	 * Shuts down all initialized repositories.
	 *
	 * @see #refresh()
	 */
	public void shutDown() {
		synchronized (initializedRepositories) {
			updateInitializedRepositories();
			for (Repository repository : initializedRepositories.values()) {
				try {
					if (repository.isInitialized()) {
						repository.shutDown();
					}
				} catch (RepositoryException e) {
					logger.error("Repository shut down failed", e);
				}
			}

			initializedRepositories.clear();
			initialized = false;
		}
	}

	void refreshRepository(String repositoryID, Repository repository) {
		logger.debug("Refreshing repository {}...", repositoryID);
		try {
			if (repository.isInitialized()) {
				repository.shutDown();
			}
		} catch (RepositoryException e) {
			logger.error("Failed to shut down repository", e);
		}
	}

	/**
	 * Gets the URL of the server or directory.
	 *
	 * @throws MalformedURLException If the location cannot be represented as a URL.
	 */
	public abstract URL getLocation() throws MalformedURLException;
}