FileVersionSqlDao.java

/*
 * Syncany, www.syncany.org
 * Copyright (C) 2011-2016 Philipp C. Heckel <philipp.heckel@gmail.com>
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */
package org.syncany.database.dao;

import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Timestamp;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Date;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
import java.util.logging.Level;
import java.util.logging.Logger;

import org.syncany.database.FileContent.FileChecksum;
import org.syncany.database.FileVersion;
import org.syncany.database.FileVersion.FileStatus;
import org.syncany.database.FileVersion.FileType;
import org.syncany.database.PartialFileHistory;
import org.syncany.database.PartialFileHistory.FileHistoryId;
import org.syncany.operations.cleanup.CleanupOperationOptions.TimeUnit;
import org.syncany.util.StringUtil;

import com.google.common.collect.ImmutableMap;

/**
 * The file version DAO queries and modifies the <i>fileversion</i> in
 * the SQL database. This table corresponds to the Java object {@link FileVersion}.
 *
 * @author Philipp C. Heckel (philipp.heckel@gmail.com)
 */
public class FileVersionSqlDao extends AbstractSqlDao {
	private static final Logger logger = Logger.getLogger(FileVersionSqlDao.class.getSimpleName());	
	private static final Map<TimeUnit, String> timeUnitSqlTimeUnitMap = new ImmutableMap.Builder<TimeUnit, String>()
           .put(TimeUnit.SECONDS, "SS")
           .put(TimeUnit.MINUTES, "MI")
           .put(TimeUnit.HOURS, "HH")
           .put(TimeUnit.DAYS, "DD")
           .put(TimeUnit.WEEKS, "WW")
           .put(TimeUnit.MONTHS, "MM")
           .put(TimeUnit.YEARS, "YYY")
           .build();	
	
	public FileVersionSqlDao(Connection connection) {
		super(connection);
	}

	/**
	 * Writes a list of {@link FileVersion} to the database table <i>fileversion</i> using <code>INSERT</code>s
	 * and the given connection.
	 *
	 * <p><b>Note:</b> This method executes, but <b>does not commit</b> the queries.
	 *
	 * @param connection The connection used to execute the statements
	 * @param fileHistoryId References the {@link PartialFileHistory} to which the list of file versions belongs
	 * @param databaseVersionId References the {@link PartialFileHistory} to which the list of file versions belongs
	 * @param fileVersions List of {@link FileVersion}s to be written to the database
	 * @throws SQLException If the SQL statement fails
	 */
	public void writeFileVersions(Connection connection, FileHistoryId fileHistoryId, long databaseVersionId, Collection<FileVersion> fileVersions)
			throws SQLException {
		PreparedStatement preparedStatement = getStatement(connection, "fileversion.insert.writeFileVersions.sql");

		for (FileVersion fileVersion : fileVersions) {
			String fileContentChecksumStr = (fileVersion.getChecksum() != null) ? fileVersion.getChecksum().toString() : null;

			preparedStatement.setString(1, fileHistoryId.toString());
			preparedStatement.setInt(2, Integer.parseInt("" + fileVersion.getVersion()));
			preparedStatement.setLong(3, databaseVersionId);
			preparedStatement.setString(4, fileVersion.getPath());
			preparedStatement.setString(5, fileVersion.getType().toString());
			preparedStatement.setString(6, fileVersion.getStatus().toString());
			preparedStatement.setLong(7, fileVersion.getSize());
			preparedStatement.setTimestamp(8, new Timestamp(fileVersion.getLastModified().getTime()));
			preparedStatement.setString(9, fileVersion.getLinkTarget());
			preparedStatement.setString(10, fileContentChecksumStr);
			preparedStatement.setTimestamp(11, new Timestamp(fileVersion.getUpdated().getTime()));
			preparedStatement.setString(12, fileVersion.getPosixPermissions());
			preparedStatement.setString(13, fileVersion.getDosAttributes());

			preparedStatement.addBatch();
		}

		preparedStatement.executeBatch();
		preparedStatement.close();
	}

	/**
	 * Removes {@link FileVersion}s from the database table <i>fileversion</i> for which the
	 * the corresponding database is marked <code>DIRTY</code>.
	 *
	 * <p><b>Note:</b> This method executes, but does not commit the query.
	 *
	 * @throws SQLException If the SQL statement fails
	 */
	public void removeDirtyFileVersions() throws SQLException {
		try (PreparedStatement preparedStatement = getStatement("fileversion.delete.dirty.removeDirtyFileVersions.sql")) {
			preparedStatement.executeUpdate();
		}
	}

	/**
	 * Removes all file versions with versions <b>lower or equal</b> than the given file version.
	 *
	 * <p>Note that this method does not just delete the given file version, but also all of its
	 * previous versions.
	 */
	public void removeFileVersions(Map<FileHistoryId, FileVersion> purgeFileVersions) throws SQLException {
		if (purgeFileVersions.size() > 0) {
			try (PreparedStatement preparedStatement = getStatement(connection, "fileversion.delete.all.removeFileVersionsByIds.sql")) {
				for (Map.Entry<FileHistoryId, FileVersion> purgeFileVersionEntry : purgeFileVersions.entrySet()) {
					FileHistoryId purgeFileHistoryId = purgeFileVersionEntry.getKey();
					FileVersion purgeFileVersion = purgeFileVersionEntry.getValue();

					preparedStatement.setString(1, purgeFileHistoryId.toString());
					preparedStatement.setLong(2, purgeFileVersion.getVersion());

					preparedStatement.addBatch();
				}

				preparedStatement.executeBatch();
			}
		}
	}

	public void removeSpecificFileVersions(Map<FileHistoryId, List<FileVersion>> purgeFileVersions) throws SQLException {
		if (purgeFileVersions.size() > 0) {
			try (PreparedStatement preparedStatement = getStatement(connection, "fileversion.delete.all.removeSpecificFileVersionsByIds.sql")) {
				for (FileHistoryId purgeFileHistoryId : purgeFileVersions.keySet()) {
					for (FileVersion purgeFileVersion : purgeFileVersions.get(purgeFileHistoryId)) {
						preparedStatement.setString(1, purgeFileHistoryId.toString());
						preparedStatement.setLong(2, purgeFileVersion.getVersion());

						preparedStatement.addBatch();
					}
				}

				preparedStatement.executeBatch();
			}
		}
	}

	/**
	 * Queries the database for the currently active {@link FileVersion}s and returns it
	 * as a map. If the current file tree (on the disk) has not changed, the result will
	 * match the files on the disk.
	 *
	 * <p>Keys in the returned map correspond to the file version's relative file path,
	 * and values to the actual {@link FileVersion} object.
	 *
	 * @return Returns the current file tree as a map of relative paths to {@link FileVersion} objects
	 */
	public Map<String, FileVersion> getCurrentFileTree() {
		try (PreparedStatement preparedStatement = getStatement("fileversion.select.master.getCurrentFileTree.sql")) {
			Map<String, FileVersion> fileTree = new TreeMap<>();
			List<FileVersion> fileList = getFileTree(preparedStatement);
			
			for (FileVersion fileVersion : fileList) {
				fileTree.put(fileVersion.getPath(), fileVersion);
			}
			
			return fileTree;
			
		}
		catch (SQLException e) {
			throw new RuntimeException(e);
		}
	}

	public List<FileVersion> getFileHistory(FileHistoryId fileHistoryId) {
		try (PreparedStatement preparedStatement = getStatement("fileversion.select.master.getFileHistoryById.sql")) {
			preparedStatement.setString(1, fileHistoryId.toString());

			List<FileVersion> fileTree = new ArrayList<FileVersion>();

			try (ResultSet resultSet = preparedStatement.executeQuery()) {
				while (resultSet.next()) {
					FileVersion fileVersion = createFileVersionFromRow(resultSet);
					fileTree.add(fileVersion);
				}

				return fileTree;
			}
			catch (SQLException e) {
				throw new RuntimeException(e);
			}
		}
		catch (SQLException e) {
			throw new RuntimeException(e);
		}
	}

	public List<FileVersion> getFileList(String pathExpression, Date date, boolean fileHistoryId, boolean recursive, boolean deleted,
			Set<FileType> fileTypes) {
		
		// Determine sensible query parameters
		// Basic idea: If null/empty given, match them all!

		String fileHistoryPrefix = null;
		
		if (fileHistoryId) {
			fileHistoryPrefix = (pathExpression == null || "".equals(pathExpression)) ? "%" : pathExpression;
			pathExpression = "%";
		}
		else {
			fileHistoryPrefix = "%";
			pathExpression = (pathExpression == null || "".equals(pathExpression)) ? "%" : pathExpression;
		}
		
		date = (date == null) ? new Date(4133984461000L) : date;

		int slashCount = StringUtil.substrCount(pathExpression, "/");
		int filterMinSlashCount = (recursive || fileHistoryId) ? 0 : slashCount;
		int filterMaxSlashCount = (recursive || fileHistoryId) ? Integer.MAX_VALUE : slashCount;

		String[] fileTypesStr = createFileTypesArray(fileTypes);		
		String fileStatusNotEqualTo = (deleted) ? "INVALID" : FileStatus.DELETED.toString(); 
		
		if (logger.isLoggable(Level.INFO)) {
			logger.log(Level.INFO, " getFileTree(path = " + pathExpression + ", history = " + fileHistoryPrefix + ", minSlash = "
					+ filterMinSlashCount + ", maxSlash = " + filterMaxSlashCount + ", date <= " + date + ", types = " 
					+ StringUtil.join(fileTypesStr, ", "));
		}

		try (PreparedStatement preparedStatement = getStatement("fileversion.select.master.getFilteredFileTree.sql")) {
			preparedStatement.setString(1, fileStatusNotEqualTo);
			preparedStatement.setString(2, pathExpression);
			preparedStatement.setString(3, fileHistoryPrefix);
			preparedStatement.setInt(4, filterMinSlashCount);
			preparedStatement.setInt(5, filterMaxSlashCount);
			preparedStatement.setArray(6, connection.createArrayOf("varchar", fileTypesStr));
			preparedStatement.setTimestamp(7, new Timestamp(date.getTime()));

			return getFileTree(preparedStatement);
		}
		catch (SQLException e) {
			throw new RuntimeException(e);
		}
	}

	private String[] createFileTypesArray(Set<FileType> fileTypes) {
		String[] fileTypesStr = null;

		if (fileTypes != null) {
			fileTypesStr = new String[fileTypes.size()];

			int i = 0;

			for (Iterator<FileType> fileTypeIterator = fileTypes.iterator(); fileTypeIterator.hasNext();) {
				fileTypesStr[i++] = fileTypeIterator.next().toString();
			}
		}
		else {
			fileTypesStr = new String[] { FileType.FILE.toString(), FileType.FOLDER.toString(), FileType.SYMLINK.toString() };
		}

		return fileTypesStr;
	}

	public Map<FileHistoryId, List<FileVersion>> getFileHistoriesToPurgeInInterval(long beginTimestamp, long endTimestamp, TimeUnit timeUnit) {
		try (PreparedStatement preparedStatement = getStatement("fileversion.select.all.getPurgeVersionsByInterval.sql")) {
			String timeUnitIdentifier = timeUnitSqlTimeUnitMap.get(timeUnit);
			
			preparedStatement.setString(1, timeUnitIdentifier);
			preparedStatement.setTimestamp(2, new Timestamp(beginTimestamp));
			preparedStatement.setTimestamp(3, new Timestamp(endTimestamp));
			
			return getAllVersionsInQuery(preparedStatement);
		}
		catch (SQLException e) {
			throw new RuntimeException(e);
		}
	}

	public Map<FileHistoryId, List<FileVersion>> getFileHistoriesToPurgeBefore(long timestamp) {
		try (PreparedStatement preparedStatement = getStatement("fileversion.select.all.getPurgeVersionsBeforeTime.sql")) {
			preparedStatement.setTimestamp(1, new Timestamp(timestamp));
			return getAllVersionsInQuery(preparedStatement);
		}
		catch (SQLException e) {
			throw new RuntimeException(e);
		}
	}
	
	public Map<FileHistoryId, FileVersion> getDeletedFileVersionsBefore(long timestamp) {
		try (PreparedStatement preparedStatement = getStatement("fileversion.select.all.getDeletedFileVersionsBefore.sql")) {
			preparedStatement.setTimestamp(1, new Timestamp(timestamp));
			return getSingleVersionInHistory(preparedStatement);
		}
		catch (SQLException e) {
			throw new RuntimeException(e);
		}
		
	}

	public FileVersion getFileVersion(FileHistoryId fileHistoryId, long version) {
		try (PreparedStatement preparedStatement = getStatement("fileversion.select.master.getFileVersionByHistoryAndVersion.sql")) {
			preparedStatement.setString(1, fileHistoryId.toString());
			preparedStatement.setLong(2, version);

			return executeAndCreateFileVersion(preparedStatement);
		}
		catch (SQLException e) {
			throw new RuntimeException(e);
		}
	}

	private Map<FileHistoryId, FileVersion> getSingleVersionInHistory(PreparedStatement preparedStatement) throws SQLException {
		try (ResultSet resultSet = preparedStatement.executeQuery()) {
			Map<FileHistoryId, FileVersion> mostRecentPurgeFileVersions = new HashMap<FileHistoryId, FileVersion>();

			while (resultSet.next()) {
				FileHistoryId fileHistoryId = FileHistoryId.parseFileId(resultSet.getString("filehistory_id"));
				FileVersion fileVersion = createFileVersionFromRow(resultSet);

				mostRecentPurgeFileVersions.put(fileHistoryId, fileVersion);
			}

			return mostRecentPurgeFileVersions;
		}
	}

	private Map<FileHistoryId, List<FileVersion>> getAllVersionsInQuery(PreparedStatement preparedStatement) throws SQLException {
		try (ResultSet resultSet = preparedStatement.executeQuery()) {
			Map<FileHistoryId, List<FileVersion>> fileHistoryPurgeFileVersions = new HashMap<FileHistoryId, List<FileVersion>>();

			while (resultSet.next()) {
				FileHistoryId fileHistoryId = FileHistoryId.parseFileId(resultSet.getString("filehistory_id"));
				FileVersion fileVersion = createFileVersionFromRow(resultSet);

				List<FileVersion> purgeFileVersions = fileHistoryPurgeFileVersions.get(fileHistoryId);
				
				if (purgeFileVersions == null) {
					purgeFileVersions = new ArrayList<FileVersion>();
					fileHistoryPurgeFileVersions.put(fileHistoryId, purgeFileVersions);
				}
				
				purgeFileVersions.add(fileVersion);
			}

			return fileHistoryPurgeFileVersions;
		}
	}

	private List<FileVersion> getFileTree(PreparedStatement preparedStatement) {
		List<FileVersion> fileTree = new ArrayList<>();

		try (ResultSet resultSet = preparedStatement.executeQuery()) {
			while (resultSet.next()) {
				FileVersion fileVersion = createFileVersionFromRow(resultSet);
				fileTree.add(fileVersion);
			}

			return fileTree;
		}
		catch (SQLException e) {
			throw new RuntimeException(e);
		}
	}

	private FileVersion executeAndCreateFileVersion(PreparedStatement preparedStatement) {
		try (ResultSet resultSet = preparedStatement.executeQuery()) {
			if (resultSet.next()) {
				return createFileVersionFromRow(resultSet);
			}
			else {
				return null;
			}
		}
		catch (SQLException e) {
			throw new RuntimeException(e);
		}
	}

	// TODO [low] This should be private; but it has to be public for a test
	public FileVersion createFileVersionFromRow(ResultSet resultSet) throws SQLException {
		FileVersion fileVersion = new FileVersion();

		fileVersion.setFileHistoryId(FileHistoryId.parseFileId(resultSet.getString("filehistory_id")));
		fileVersion.setVersion(resultSet.getLong("version"));
		fileVersion.setPath(resultSet.getString("path"));
		fileVersion.setType(FileType.valueOf(resultSet.getString("type")));
		fileVersion.setStatus(FileStatus.valueOf(resultSet.getString("status")));
		fileVersion.setSize(resultSet.getLong("size"));
		fileVersion.setLastModified(new Date(resultSet.getTimestamp("lastmodified").getTime()));

		if (resultSet.getString("linktarget") != null) {
			fileVersion.setLinkTarget(resultSet.getString("linktarget"));
		}

		if (resultSet.getString("filecontent_checksum") != null) {
			FileChecksum fileChecksum = FileChecksum.parseFileChecksum(resultSet.getString("filecontent_checksum"));
			fileVersion.setChecksum(fileChecksum);
		}

		if (resultSet.getString("updated") != null) {
			fileVersion.setUpdated(new Date(resultSet.getTimestamp("updated").getTime()));
		}

		if (resultSet.getString("posixperms") != null) {
			fileVersion.setPosixPermissions(resultSet.getString("posixperms"));
		}

		if (resultSet.getString("dosattrs") != null) {
			fileVersion.setDosAttributes(resultSet.getString("dosattrs"));
		}

		return fileVersion;
	}


}