FileHistorySqlDao.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.List;
import java.util.Map;

import org.syncany.database.DatabaseVersion.DatabaseVersionStatus;
import org.syncany.database.FileVersion;
import org.syncany.database.PartialFileHistory;
import org.syncany.database.PartialFileHistory.FileHistoryId;
import org.syncany.database.VectorClock;

import com.google.common.base.Function;
import com.google.common.collect.Lists;

/**
 * The file history DAO queries and modifies the <i>filehistory</i> in
 * the SQL database. This table corresponds to the Java object {@link PartialFileHistory}.
 *
 * @author Philipp C. Heckel (philipp.heckel@gmail.com)
 */
public class FileHistorySqlDao extends AbstractSqlDao {
	private FileVersionSqlDao fileVersionDao;

	public FileHistorySqlDao(Connection connection, FileVersionSqlDao fileVersionDao) {
		super(connection);
		this.fileVersionDao = fileVersionDao;
	}

	/**
	 * Writes a list of {@link PartialFileHistory}s to the database table <i>filehistory</i> using <code>INSERT</code>s
	 * and the given connection. In addition, this method also writes the corresponding {@link FileVersion}s of
	 * each file history to the database using
	 * {@link FileVersionSqlDao#writeFileVersions(Connection, FileHistoryId, long, Collection) FileVersionSqlDao#writeFileVersions}.
	 *
	 * <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 databaseVersionId References the {@link PartialFileHistory} to which the list of file versions belongs
	 * @param fileHistories List of {@link PartialFileHistory}s to be written to the database
	 * @throws SQLException If the SQL statement fails
	 */
	public void writeFileHistories(Connection connection, long databaseVersionId, Collection<PartialFileHistory> fileHistories) throws SQLException {
		for (PartialFileHistory fileHistory : fileHistories) {
			PreparedStatement preparedStatement = getStatement(connection, "filehistory.insert.all.writeFileHistories.sql");

			preparedStatement.setString(1, fileHistory.getFileHistoryId().toString());
			preparedStatement.setLong(2, databaseVersionId);

			int affectedRows = preparedStatement.executeUpdate();

			if (affectedRows == 0) {
				throw new SQLException("Cannot add database version header. Affected rows is zero.");
			}

			preparedStatement.close();

			fileVersionDao.writeFileVersions(connection, fileHistory.getFileHistoryId(), databaseVersionId, fileHistory.getFileVersions().values());
		}
	}

	public void removeDirtyFileHistories() throws SQLException {
		try (PreparedStatement preparedStatement = getStatement("filehistory.delete.dirty.removeDirtyFileHistories.sql")) {
			preparedStatement.executeUpdate();
		}
	}

	/**
	 * Removes unreferenced {@link PartialFileHistory}s from the database table
	 * <i>filehistory</i>. This method <b>does not</b> remove the corresponding {@link FileVersion}s.
	 *
	 * <p><b>Note:</b> This method executes, but <b>does not commit</b> the query.
	 *
	 * @throws SQLException If the SQL statement fails
	 */
	public void removeUnreferencedFileHistories() throws SQLException {
		try (PreparedStatement preparedStatement = getStatement("filehistory.delete.all.removeUnreferencedFileHistories.sql")) {
			preparedStatement.executeUpdate();
		}
	}
	
	/**
	 * Note: Also selects versions marked as {@link DatabaseVersionStatus#DIRTY DIRTY}
	 */
	public Map<FileHistoryId, PartialFileHistory> getFileHistoriesWithFileVersions(VectorClock databaseVersionVectorClock, int maxCount) {
		try (PreparedStatement preparedStatement = getStatement("filehistory.select.all.getFileHistoriesWithFileVersionsByVectorClock.sql")) {
			preparedStatement.setString(1, databaseVersionVectorClock.toString());
			
			if (maxCount > 0) {
				preparedStatement.setMaxRows(maxCount);
			}

			try (ResultSet resultSet = preparedStatement.executeQuery()) {
				return createFileHistoriesFromResult(resultSet);
			}
		}
		catch (SQLException e) {
			throw new RuntimeException(e);
		}
	}

	public FileHistoryId expandFileHistoryId(FileHistoryId fileHistoryIdPrefix) {
		String fileHistoryIdPrefixLikeQuery = fileHistoryIdPrefix.toString() + "%";

		try (PreparedStatement preparedStatement = getStatement("filehistory.select.master.expandFileHistoryId.sql")) {
			preparedStatement.setString(1, fileHistoryIdPrefixLikeQuery);

			try (ResultSet resultSet = preparedStatement.executeQuery()) {
				if (resultSet.next()) {
					FileHistoryId fullFileHistoryId = FileHistoryId.parseFileId(resultSet.getString("filehistory_id"));

					boolean nonUniqueResult = resultSet.next();

					if (nonUniqueResult) {
						return null;
					}
					else {
						return fullFileHistoryId;
					}
				}
				else {
					return null;
				}
			}
		}
		catch (SQLException e) {
			throw new RuntimeException(e);
		}
	}

	public Map<FileHistoryId, PartialFileHistory> getFileHistories(List<FileHistoryId> fileHistoryIds) {
		String[] fileHistoryIdsStr = createFileHistoryIdsArray(fileHistoryIds);

		try (PreparedStatement preparedStatement = getStatement("filehistory.select.master.getFileHistoriesByIds.sql")) {
			preparedStatement.setArray(1, connection.createArrayOf("varchar", fileHistoryIdsStr));

			try (ResultSet resultSet = preparedStatement.executeQuery()) {
				return createFileHistoriesFromResult(resultSet);
			}
		}
		catch (SQLException e) {
			throw new RuntimeException(e);
		}
	}

	/**
	 * This function returns FileHistories with the last version for which this last version
	 * matches the given checksum, size and modified date. 
	 * 
	 * @return An empty Collection is returned if none exist.
	 */
	public Collection<PartialFileHistory> getFileHistoriesByChecksumSizeAndModifiedDate(String filecontentChecksum, long size, Date modifiedDate) {
		try (PreparedStatement preparedStatement = getStatement("filehistory.select.master.getFileHistoriesByChecksumSizeAndModifiedDate.sql")) {
			// This first query retrieves the last version for each FileHistory matching the three requested properties.
			// However, it does not guarantee that this version is indeed the last version in that particular
			// FileHistory, so we need another query to verify that.

			preparedStatement.setString(1, filecontentChecksum);
			preparedStatement.setLong(2, size);
			preparedStatement.setTimestamp(3, new Timestamp(modifiedDate.getTime()));

			try (ResultSet resultSet = preparedStatement.executeQuery()) {
				Collection<PartialFileHistory> fileHistories = new ArrayList<>();
				
				while (resultSet.next()) {
					String fileHistoryId = resultSet.getString("filehistory_id");
					PartialFileHistory fileHistory = getLastVersionByFileHistoryId(fileHistoryId);
					
					boolean resultIsLatestVersion = fileHistory.getLastVersion().getVersion() == resultSet.getLong("version");
					boolean resultIsNotDelete = fileHistory.getLastVersion().getStatus() != FileVersion.FileStatus.DELETED;

					// Only if the result is indeed the last in it's history, we can use it
					// to base other versions off it. So we return it.
					
					if (resultIsLatestVersion && resultIsNotDelete) {
						fileHistories.add(fileHistory);
					}
				}
				
				return fileHistories;
			}

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

	/**
	 * This function returns a FileHistory, with as last version a FileVersion with
	 * the given path. 
	 * 
	 * If the last FileVersion referring to this path is not the last in the
	 * FileHistory, or no such FileVersion exists, null is returned.
	 */
	public PartialFileHistory getFileHistoryWithLastVersionByPath(String path) {
		try (PreparedStatement preparedStatement = getStatement("filehistory.select.master.findLatestFileVersionsForPath.sql")) {
			preparedStatement.setString(1, path);

			try (ResultSet resultSet = preparedStatement.executeQuery()) {
				// Fetch the latest versions of all files that once existed with the given
				// path and find the most recent by comparing vector clocks

				String latestFileHistoryId = null;
				Long latestFileVersion = null;
				VectorClock latestVectorClock = null;

				while (resultSet.next()) {
					VectorClock resultSetVectorClock = VectorClock.parseVectorClock(resultSet.getString("vectorclock_serialized"));
					boolean vectorClockIsGreater = latestVectorClock == null
							|| VectorClock.compare(resultSetVectorClock, latestVectorClock) == VectorClock.VectorClockComparison.GREATER;

					if (vectorClockIsGreater) {
						latestVectorClock = resultSetVectorClock;
						latestFileHistoryId = resultSet.getString("filehistory_id");
						latestFileVersion = resultSet.getLong("version");
					}
				}

				// If no active file history exists for this path, return
				if (latestFileHistoryId == null) {
					return null;
				}

				// Get the last FileVersion of the FileHistory in the database with the largest vectorclock.
				PartialFileHistory fileHistory = getLastVersionByFileHistoryId(latestFileHistoryId);
				
				// The above query does not guarantee the resulting version is the last in its
				// history. We need to check this before returning the file.
				if (fileHistory.getLastVersion().getVersion() == latestFileVersion) {
					return fileHistory;
				}
				else {
					// The version retrieved by the path query is not a fileversion which is in the current
					// filetree. Since it was the last version with this path, there is no other history
					// which should be continued.
					return null;
				}
			}
		}
		catch (SQLException e) {
			throw new RuntimeException(e);
		}
	}

	private PartialFileHistory getLastVersionByFileHistoryId(String fileHistoryId) {
		try (PreparedStatement preparedStatement = getStatement("filehistory.select.master.getLastVersionByFileHistoryId.sql")) {
			preparedStatement.setString(1, fileHistoryId);
			preparedStatement.setString(2, fileHistoryId);

			try (ResultSet resultSet = preparedStatement.executeQuery()) {
				if (resultSet.next()) {
					FileVersion lastFileVersion = fileVersionDao.createFileVersionFromRow(resultSet);
					FileHistoryId fileHistoryIdData = FileHistoryId.parseFileId(resultSet.getString("filehistory_id"));

					PartialFileHistory fileHistory = new PartialFileHistory(fileHistoryIdData);
					fileHistory.addFileVersion(lastFileVersion);
					
					return fileHistory;
				}
				else {
					return null;
				}
			}
		}
		catch (SQLException e) {
			throw new RuntimeException(e);
		}
	}

	private String[] createFileHistoryIdsArray(List<FileHistoryId> fileHistoryIds) {
		return Lists.transform(fileHistoryIds, new Function<FileHistoryId, String>() {
			@Override
			public String apply(FileHistoryId fileHistoryId) {
				return fileHistoryId.toString();
			}
		}).toArray(new String[0]);
	}

	public Map<FileHistoryId, PartialFileHistory> getFileHistoriesWithFileVersions() {
		try (PreparedStatement preparedStatement = getStatement("filehistory.select.master.getFileHistoriesWithFileVersions.sql")) {
			try (ResultSet resultSet = preparedStatement.executeQuery()) {
				return createFileHistoriesFromResult(resultSet);
			}
		}
		catch (SQLException e) {
			throw new RuntimeException(e);
		}
	}
	
	protected Map<FileHistoryId, PartialFileHistory> createFileHistoriesFromResult(ResultSet resultSet) throws SQLException {
		Map<FileHistoryId, PartialFileHistory> fileHistories = new HashMap<FileHistoryId, PartialFileHistory>();
		PartialFileHistory fileHistory = null;

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

			// Old history (= same filehistory identifier)
			if (fileHistory != null && fileHistory.getFileHistoryId().equals(fileHistoryId)) { // Same history!
				fileHistory.addFileVersion(lastFileVersion);
			}

			// New history!
			else {
				// Add the old history
				if (fileHistory != null) {
					fileHistories.put(fileHistory.getFileHistoryId(), fileHistory);
				}

				// Create a new one
				fileHistory = new PartialFileHistory(fileHistoryId);
				fileHistory.addFileVersion(lastFileVersion);
			}
		}

		// Add the last history
		if (fileHistory != null) {
			fileHistories.put(fileHistory.getFileHistoryId(), fileHistory);
		}

		return fileHistories;
	}

	public List<PartialFileHistory> getFileHistoriesWithLastVersion() {
		List<PartialFileHistory> fileHistories = new ArrayList<PartialFileHistory>();

		try (PreparedStatement preparedStatement = getStatement("filehistory.select.master.getFileHistoriesWithLastVersion.sql")) {
			try (ResultSet resultSet = preparedStatement.executeQuery()) {
				while (resultSet.next()) {
					FileHistoryId fileHistoryId = FileHistoryId.parseFileId(resultSet.getString("filehistory_id"));
					FileVersion lastFileVersion = fileVersionDao.createFileVersionFromRow(resultSet);

					PartialFileHistory fileHistory = new PartialFileHistory(fileHistoryId);
					fileHistory.addFileVersion(lastFileVersion);

					fileHistories.add(fileHistory);
				}
			}

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