DBDatabaseClusterWithConfigFile.java

/*
 * Copyright 2018 gregorygraham.
 *
 * This work is licensed under the Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License. 
 * To view a copy of this license, visit http://creativecommons.org/licenses/by-nc-sa/4.0/ 
 * or send a letter to Creative Commons, PO Box 1866, Mountain View, CA 94042, USA.
 * 
 * You are free to:
 *     Share - copy and redistribute the material in any medium or format
 *     Adapt - remix, transform, and build upon the material
 * 
 *     The licensor cannot revoke these freedoms as long as you follow the license terms.               
 *     Under the following terms:
 *                 
 *         Attribution - 
 *             You must give appropriate credit, provide a link to the license, and indicate if changes were made. 
 *             You may do so in any reasonable manner, but not in any way that suggests the licensor endorses you or your use.
 *         NonCommercial - 
 *             You may not use the material for commercial purposes.
 *         ShareAlike - 
 *             If you remix, transform, or build upon the material, 
 *             you must distribute your contributions under the same license as the original.
 *         No additional restrictions - 
 *             You may not apply legal terms or technological measures that legally restrict others from doing anything the 
 *             license permits.
 * 
 * Check the Creative Commons website for any details, legalese, and updates.
 */
package nz.co.gregs.dbvolution.databases;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.dataformat.yaml.*;
import java.io.File;
import java.io.IOException;
import java.lang.reflect.InvocationTargetException;
import java.nio.file.FileVisitResult;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.attribute.BasicFileAttributes;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.List;
import java.util.logging.Level;
import java.util.logging.Logger;
import nz.co.gregs.dbvolution.exceptions.UnableToRemoveLastDatabaseFromClusterException;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;

/**
 * Creates a DBDatabaseCluster based on the information in yamlConfigFilename.
 *
 * <p>
 * Searches the application's file system from "." looking for a file named
 * yamlConfigFilename and uses the details within to create a
 * DBDatabaseCluster.</p>
 *
 * <p>
 * the file can be placed anywhere in the file system below "." but must be
 * named exactly at specified in the constructor. Only the first such file found
 * will be used and the search order is not defined so avoid creating multiple
 * versions of yamlConfigFilename.</p>
 *
 * <p>
 * The file's contents must be YAML for an array of
 * {@link DatabaseConnectionSettings} objects. Each DatabaseConnectionSettings
 * object has a DBDatabase's canonical class name, username and password, and
 * either a JDBC URL or a combination of host, port, instance, database, schema,
 * and extras specifying the configuration of the DBDatabase. The DBDatabase
 * constructor that takes a DatabaseConnectionSettings (and only that) will be
 * called and the resulting DBDatabase instance will be added to the
 * cluster.</p>
 * <p>
 * Example yamlConfigFilename contents:</p>
 * <pre>
 * ---
 * - dbdatabase: "nz.co.gregs.dbvolution.databases.H2MemoryDB"
 *   url: "jdbc:h2:mem:TestDatabase.h2"
 *   username: "admin"
 *   password: "admin"
 * - dbdatabase: "nz.co.gregs.dbvolution.databases.MySQLDB"
 *   username: "admin"
 *   host: "myserver.com"
 *   port: "40006"
 *   instance: "myinstance"
 *   database: "appdatabase"
 *   schema: "default"</pre>
 *
 * <p>
 * DBDatabase classes without a url/username/password based constructor cannot
 * be created. Note that this means you cannot add a DBDatabaseCluster to this
 * cluster via this method.</p>
 *
 * @author gregorygraham
 */
public class DBDatabaseClusterWithConfigFile extends DBDatabaseCluster {
	
	static final private Log LOG = LogFactory.getLog(DBDatabaseClusterWithConfigFile.class);

	static final long serialVersionUID = 1L;

	private final String yamlConfigFilename;

	/**
	 * Creates a DBDatabaseCluster based on the information in yamlConfigFilename.
	 *
	 * <p>
	 * Searches the application's file system from "." looking for a file named
	 * yamlConfigFilename and uses the details within to create a
	 * DBDatabaseCluster.</p>
	 *
	 * <p>
	 * the file can be placed anywhere in the file system below "." but must be
	 * named exactly at specified in the constructor. Only the first such file
	 * found will be used and the search order is not defined so avoid creating
	 * multiple versions of yamlConfigFilename.</p>
	 *
	 * <p>
	 * The file's contents must be YAML for an array of
	 * {@link DatabaseConnectionSettings} objects. Each DatabaseConnectionSettings
	 * object has a DBDatabase's canonical class name, username and password, and
	 * either a JDBC URL or a combination of host, port, instance, database,
	 * schema, and extras specifying the configuration of the DBDatabase. The
	 * DBDatabase constructor that takes a DatabaseConnectionSettings (and only
	 * that) will be called and the resulting DBDatabase instance will be added to
	 * the cluster.</p>
	 * <p>
	 * Example yamlConfigFilename contents:</p>
	 * <pre>
	 * ---
	 * - dbdatabase: "nz.co.gregs.dbvolution.databases.H2MemoryDB"
	 *   url: "jdbc:h2:mem:TestDatabase.h2"
	 *   username: "admin"
	 *   password: "admin"
	 * - dbdatabase: "nz.co.gregs.dbvolution.databases.MySQLDB"
	 *   username: "admin"
	 *   host: "myserver.com"
	 *   port: "40006"
	 *   instance: "myinstance"
	 *   database: "appdatabase"
	 *   schema: "default"</pre>
	 *
	 * <p>
	 * DBDatabase classes without a url/username/password based constructor cannot
	 * be created. Note that this means you cannot add a DBDatabaseCluster to this
	 * cluster via this method.</p>
	 *
	 * @author gregorygraham
	 * @param clusterName a label for the cluster
	 * @param config configuration settings for this cluster
	 * @param yamlConfigFilename a YAML configuration file of database to add to the cluster
	 * @throws DBDatabaseClusterWithConfigFile.NoDatabaseConfigurationFound Unable to find the YAML configuration file
	 * @throws DBDatabaseClusterWithConfigFile.UnableToCreateDatabaseCluster thrown if there are errors during cluster creation
	 * @throws java.sql.SQLException if the database connection has an issue
	 */
	public DBDatabaseClusterWithConfigFile(String clusterName, Configuration config, String yamlConfigFilename) throws NoDatabaseConfigurationFound, UnableToCreateDatabaseCluster, SQLException {
		super(clusterName, config);
		this.yamlConfigFilename = yamlConfigFilename;
		findDatabaseConfigurationAndApply(yamlConfigFilename);
	}

	/**
	 * Creates a DBDatabaseCluster based on the information in yamlConfigFile.
	 *
	 * <p>
	 * Uses the details within the file specified to create a
	 * DBDatabaseCluster.</p>
	 *
	 * <p>
	 * The file's contents must be YAML for an array of
	 * {@link DatabaseConnectionSettings} objects. Each DatabaseConnectionSettings
	 * object has a DBDatabase's canonical class name, username and password, and
	 * either a JDBC URL or a combination of host, port, instance, database,
	 * schema, and extras specifying the configuration of the DBDatabase. The
	 * DBDatabase constructor that takes a DatabaseConnectionSettings (and only
	 * that) will be called and the resulting DBDatabase instance will be added to
	 * the cluster.</p>
	 * <p>
	 * Example yamlConfigFile contents:</p>
	 * <pre>
	 * ---
	 * - dbdatabase: "nz.co.gregs.dbvolution.databases.H2MemoryDB"
	 *   url: "jdbc:h2:mem:TestDatabase.h2"
	 *   username: "admin"
	 *   password: "admin"
	 * - dbdatabase: "nz.co.gregs.dbvolution.databases.MySQLDB"
	 *   username: "admin"
	 *   host: "myserver.com"
	 *   port: "40006"
	 *   instance: "myinstance"
	 *   database: "appdatabase"
	 *   schema: "default"</pre>
	 *
	 * <p>
	 * DBDatabase classes without a url/username/password based constructor cannot
	 * be created. Note that this means you cannot add a DBDatabaseCluster to this
	 * cluster via this method.</p>
	 *
	 * @author gregorygraham
	 * @param clusterName a label for the custer
	 * @param config the cluster configuration
	 * @param yamlConfigFile the YAML configuration file
	 * @throws DBDatabaseClusterWithConfigFile.NoDatabaseConfigurationFound Unable to find the YAML file
	 * @throws DBDatabaseClusterWithConfigFile.UnableToCreateDatabaseCluster unable to create the databases
	 * @throws java.sql.SQLException if the database connection has an issue
	 */
	public DBDatabaseClusterWithConfigFile(String clusterName, Configuration config, File yamlConfigFile) throws NoDatabaseConfigurationFound, UnableToCreateDatabaseCluster, SQLException {
		super(clusterName, config);
		this.yamlConfigFilename = yamlConfigFile.getName();
		parseYAMLAndAddDatabases(yamlConfigFile, yamlConfigFilename);
	}

	public void reloadConfiguration() throws NoDatabaseConfigurationFound, UnableToCreateDatabaseCluster, UnableToRemoveLastDatabaseFromClusterException, SQLException {
		this.removeDatabases(getDetails().getAllDatabases());
		findDatabaseConfigurationAndApply(yamlConfigFilename);
	}

	private void findDatabaseConfigurationAndApply(String yamlConfigFilename) throws NoDatabaseConfigurationFound, UnableToCreateDatabaseCluster {
		try {
			final DefaultConfigFinder finder = new DefaultConfigFinder(yamlConfigFilename);
			Files.walkFileTree(Paths.get("."), finder);
			if (finder.configPath != null) {
				Path filePath = finder.configPath;
				File file = filePath.toFile();

				parseYAMLAndAddDatabases(file, yamlConfigFilename);
				LOG.info("Completed Database");
			} else {
				throw new NoDatabaseConfigurationFound(yamlConfigFilename);
			}
		} catch (IOException ex) {
			Logger.getLogger(DBDatabaseClusterWithConfigFile.class.getName()).log(Level.SEVERE, null, ex);
			throw new UnableToCreateDatabaseCluster(ex);
		}
	}

	private void parseYAMLAndAddDatabases(File file, String yamlConfigFilename) throws UnableToCreateDatabaseCluster {
		try {
			final YAMLFactory yamlFactory = new YAMLFactory();
			YAMLParser parser = yamlFactory.createParser(file);
			ObjectMapper mapper = new ObjectMapper(yamlFactory);
			DatabaseConnectionSettings[] settingsArray = mapper.readValue(parser, DatabaseConnectionSettings[].class);
			if (settingsArray.length == 0) {
				throw new NoDatabaseConfigurationFound(yamlConfigFilename);
			} else {
				for (DatabaseConnectionSettings settings : settingsArray) {

					DBDatabase database = settings.createDBDatabase();

					if (database != null) {
						LOG.info("Adding Database: " + settings.getDbdatabaseClass() + ":" + database.getUrlFromSettings(settings) + ":" + settings.getUsername());
						this.addDatabaseAndWait(database);
					}
				}
			}
		} catch (IOException | NoDatabaseConfigurationFound | ClassNotFoundException | NoSuchMethodException | SecurityException | InstantiationException | IllegalAccessException | IllegalArgumentException | InvocationTargetException | SQLException ex) {
			Logger.getLogger(DBDatabaseClusterWithConfigFile.class.getName()).log(Level.SEVERE, null, ex);
			throw new UnableToCreateDatabaseCluster(ex, file);
		}
	}

	public static class DefaultConfigFinder
			extends SimpleFileVisitor<Path> {

		private int filesChecked = 0;
		private final String yamlConfigFilename;
		private Path configPath;
		private final List<String> visitedFiles = new ArrayList<>();

		DefaultConfigFinder(String yamlConfigFilename) {
			this.yamlConfigFilename = yamlConfigFilename;
		}

		// Compares the glob pattern against
		// the file or directory name.
		FileVisitResult find(Path path) {
			if (filesChecked > 100000) {
				return FileVisitResult.TERMINATE;
			}
			filesChecked++;
			if (!visited(path)) {
				Path name = path.getFileName();
				if (name != null && name.toString().equals(yamlConfigFilename)) {
					configPath = path;
					return FileVisitResult.TERMINATE;
				}
			}
			return FileVisitResult.CONTINUE;
		}

		// Invoke the pattern matching
		// method on each file.
		@Override
		public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
			return find(file);

		}

		// Invoke the pattern matching
		// method on each directory.
		@Override
		public FileVisitResult preVisitDirectory(Path dir,
				BasicFileAttributes attrs) {
			return find(dir);
		}

		@Override
		public FileVisitResult visitFileFailed(Path file, IOException exc) {
			return FileVisitResult.CONTINUE;
		}

		@Override
		public FileVisitResult postVisitDirectory(Path dir, IOException exc) throws IOException {
			if (configPath == null) {
				LOG.debug("Unable To Find Database Cluster Config File In: " + dir.toAbsolutePath().toString());
			}
			return super.postVisitDirectory(dir, exc);
		}

		private boolean visited(Path path) {
			String key = path.toAbsolutePath().toString();
			if (visitedFiles.contains(key)) {
				return true;
			} else {
				visitedFiles.add(key);
			}
			return false;
		}
	}

	public static class NoDatabaseConfigurationFound extends Exception {

		static final long serialVersionUID = 1L;

		private NoDatabaseConfigurationFound(String yamlConfigFilename) {
			super("No DBDatabase Configuration File named \"" + yamlConfigFilename + "\" was found in the filesystem: check the filename and ensure that the location is accessible from \".\"" + (Paths.get(".").toAbsolutePath()));
		}
	}

	public static class UnableToCreateDatabaseCluster extends Exception {

		static final long serialVersionUID = 1L;

		public UnableToCreateDatabaseCluster(Exception ex) {
			super("Unable Create DBDatabaseCluster Due To Exception", ex);
		}

		private UnableToCreateDatabaseCluster(Exception ex, File file) {
			super("Unable Create DBDatabaseCluster Due To Exception: "+file.getAbsolutePath(), ex);
		}
	}
}