Add queryable store with SQLite implementation

This adds the new "queryable store" API, that allows complex
queries and is backed by SQLite. This new API can be used
for entities annotated with the new QueryableType annotation.
This commit is contained in:
Rene Pfeuffer
2025-04-01 16:18:04 +02:00
parent d5362d634b
commit ada575d871
235 changed files with 10154 additions and 252 deletions

View File

@@ -0,0 +1,219 @@
/*
* Copyright (c) 2020 - present Cloudogu GmbH
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3.
*
* 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 Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
package sonia.scm;
import com.google.common.util.concurrent.Striped;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import sonia.scm.store.StoreException;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.UUID;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.function.Supplier;
/**
* CopyOnWrite creates a copy of the target file, before it is modified. This should prevent empty or incomplete files
* on errors such as full disk.
* <p>
* javasecurity:S2083: SonarQube thinks that the path (targetFile) is generated from an http header (HttpUtil), but
* this is not true. It looks like a false-positive, so we suppress the warning for now.
*/
@SuppressWarnings({"javasecurity:S2083", "UnstableApiUsage"})
public final class CopyOnWrite {
private static final Logger LOG = LoggerFactory.getLogger(CopyOnWrite.class);
private static final Striped<ReadWriteLock> concurrencyLock = Striped.readWriteLock(100);
private CopyOnWrite() {
}
public static void withTemporaryFile(FileWriter writer, Path targetFile) {
withTemporaryFile(writer, targetFile, () -> {});
}
public static void withTemporaryFile(FileWriter writer, Path targetFile, Runnable onSuccess) {
validateInput(targetFile);
execute(() -> {
Path temporaryFile = createTemporaryFile(targetFile);
try {
executeCallback(writer, targetFile, temporaryFile);
} catch (Exception e) {
LOG.warn("error writing temporary file");
deleteTemporaryFile(temporaryFile);
throw e;
}
replaceOriginalFile(targetFile, temporaryFile);
onSuccess.run();
}).withLockedFileForWrite(targetFile);
}
public static <R> FileLocker<R> compute(Supplier<R> supplier) {
return new FileLocker<>(supplier);
}
public static FileLocker<Void> execute(Runnable runnable) {
return new FileLocker<>(() -> {
runnable.run();
return null;
});
}
public static class FileLocker<R> {
private final Supplier<R> supplier;
public FileLocker(Supplier<R> supplier) {
this.supplier = supplier;
}
public R withLockedFileForRead(Path file) {
return withLockedFileForRead(file.toAbsolutePath().toString());
}
public R withLockedFileForRead(File file) {
return withLockedFileForRead(file.getPath());
}
public R withLockedFileForRead(String file) {
LOG.trace("read lock get {}", file);
Lock lock = concurrencyLock.get(file).readLock();
LOG.trace("read lock passed {}", file);
lock.lock();
try {
return supplier.get();
} finally {
lock.unlock();
LOG.trace("read lock released {}", file);
}
}
public R withLockedFileForWrite(Path file) {
return withLockedFileForWrite(file.toAbsolutePath().toString());
}
public R withLockedFileForWrite(File file) {
return withLockedFileForWrite(file.getPath());
}
public R withLockedFileForWrite(String file) {
LOG.trace("write lock get > {}", file);
Lock lock = concurrencyLock.get(file).writeLock();
LOG.trace("write lock passed {}", file);
lock.lock();
try {
return supplier.get();
} finally {
lock.unlock();
LOG.trace("write lock released {}", file);
}
}
}
@SuppressWarnings("squid:S3725") // performance of Files#isDirectory
private static void validateInput(Path targetFile) {
if (Files.isDirectory(targetFile)) {
throw new IllegalArgumentException("target file has to be a regular file, not a directory");
}
if (targetFile.getParent() == null) {
throw new IllegalArgumentException("target file has to be specified with a parent directory");
}
}
private static Path createTemporaryFile(Path targetFile) {
Path temporaryFile = targetFile.getParent().resolve(UUID.randomUUID().toString());
try {
Files.createFile(temporaryFile);
} catch (IOException ex) {
LOG.error("Error creating temporary file {} to replace file {}", temporaryFile, targetFile);
throw new StoreException("could not create temporary file", ex);
}
return temporaryFile;
}
private static void executeCallback(FileWriter writer, Path targetFile, Path temporaryFile) {
try {
writer.write(temporaryFile);
} catch (RuntimeException e) {
throw e;
} catch (Exception ex) {
LOG.error("Error writing to temporary file {}. Target file {} has not been modified", temporaryFile, targetFile);
throw new StoreException("could not write temporary file", ex);
}
}
private static void replaceOriginalFile(Path targetFile, Path temporaryFile) {
Path backupFile = backupOriginalFile(targetFile);
try {
Files.move(temporaryFile, targetFile);
} catch (IOException e) {
LOG.error("Error renaming temporary file {} to target file {}", temporaryFile, targetFile);
restoreBackup(targetFile, backupFile);
throw new StoreException("could rename temporary file to target file", e);
}
deleteTemporaryFile(backupFile);
}
@SuppressWarnings("squid:S3725") // performance of Files#exists
private static Path backupOriginalFile(Path targetFile) {
Path directory = targetFile.getParent();
if (Files.exists(targetFile)) {
Path backupFile = directory.resolve(UUID.randomUUID().toString());
try {
Files.move(targetFile, backupFile);
} catch (IOException e) {
LOG.error("Could not backup original file {}. Aborting here so that original file will not be overwritten.", targetFile);
throw new StoreException("could not create backup of file", e);
}
return backupFile;
} else {
return null;
}
}
private static void deleteTemporaryFile(Path backupFile) {
if (backupFile != null) {
try {
Files.delete(backupFile);
} catch (IOException e) {
LOG.warn("Could not delete backup file {}", backupFile);
throw new StoreException("could not delete backup file", e);
}
}
}
private static void restoreBackup(Path targetFile, Path backupFile) {
if (backupFile != null) {
try {
Files.move(backupFile, targetFile);
LOG.info("Recovered original file {} from backup", targetFile);
} catch (IOException e) {
LOG.error("Could not replace original file {} with backup file {} after failure", targetFile, backupFile);
}
}
}
@FunctionalInterface
public interface FileWriter {
@SuppressWarnings("squid:S00112") // We do not want to limit exceptions here
void write(Path t) throws Exception;
}
}

View File

@@ -0,0 +1,61 @@
/*
* Copyright (c) 2020 - present Cloudogu GmbH
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3.
*
* 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 Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
package sonia.scm.group.xml;
import com.google.inject.Inject;
import com.google.inject.Singleton;
import sonia.scm.group.Group;
import sonia.scm.group.GroupDAO;
import sonia.scm.xml.AbstractXmlDAO;
import sonia.scm.store.ConfigurationStoreFactory;
@Singleton
public class XmlGroupDAO extends AbstractXmlDAO<Group, XmlGroupDatabase>
implements GroupDAO
{
public static final String STORE_NAME = "groups";
@Inject
public XmlGroupDAO(ConfigurationStoreFactory storeFactory) {
super(storeFactory
.withType(XmlGroupDatabase.class)
.withName(STORE_NAME)
.build());
}
@Override
protected Group clone(Group group)
{
return group.clone();
}
@Override
protected XmlGroupDatabase createNewDatabase()
{
return new XmlGroupDatabase();
}
}

View File

@@ -0,0 +1,120 @@
/*
* Copyright (c) 2020 - present Cloudogu GmbH
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3.
*
* 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 Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
package sonia.scm.group.xml;
import jakarta.xml.bind.annotation.XmlAccessType;
import jakarta.xml.bind.annotation.XmlAccessorType;
import jakarta.xml.bind.annotation.XmlElement;
import jakarta.xml.bind.annotation.XmlRootElement;
import jakarta.xml.bind.annotation.adapters.XmlJavaTypeAdapter;
import sonia.scm.auditlog.AuditEntry;
import sonia.scm.group.Group;
import sonia.scm.xml.XmlDatabase;
import java.util.Collection;
import java.util.Map;
import java.util.TreeMap;
@AuditEntry(ignore = true)
@XmlRootElement(name = "group-db")
@XmlAccessorType(XmlAccessType.FIELD)
public class XmlGroupDatabase implements XmlDatabase<Group>
{
private Long creationTime;
@XmlJavaTypeAdapter(XmlGroupMapAdapter.class)
@XmlElement(name = "groups")
private Map<String, Group> groupMap = new TreeMap<>();
private Long lastModified;
public XmlGroupDatabase()
{
long c = System.currentTimeMillis();
creationTime = c;
lastModified = c;
}
@Override
public void add(Group group)
{
groupMap.put(group.getName(), group);
}
@Override
public boolean contains(String groupname)
{
return groupMap.containsKey(groupname);
}
@Override
public Group remove(String groupname)
{
return groupMap.remove(groupname);
}
@Override
public Collection<Group> values()
{
return groupMap.values();
}
@Override
public Group get(String groupname)
{
return groupMap.get(groupname);
}
@Override
public long getCreationTime()
{
return creationTime;
}
@Override
public long getLastModified()
{
return lastModified;
}
@Override
public void setCreationTime(long creationTime)
{
this.creationTime = creationTime;
}
@Override
public void setLastModified(long lastModified)
{
this.lastModified = lastModified;
}
}

View File

@@ -0,0 +1,68 @@
/*
* Copyright (c) 2020 - present Cloudogu GmbH
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3.
*
* 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 Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
package sonia.scm.group.xml;
import jakarta.xml.bind.annotation.XmlAccessType;
import jakarta.xml.bind.annotation.XmlAccessorType;
import jakarta.xml.bind.annotation.XmlElement;
import jakarta.xml.bind.annotation.XmlRootElement;
import sonia.scm.group.Group;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.Map;
@XmlRootElement(name = "groups")
@XmlAccessorType(XmlAccessType.FIELD)
public class XmlGroupList implements Iterable<Group>
{
@XmlElement(name = "group")
private LinkedList<Group> groups;
public XmlGroupList() {}
public XmlGroupList(Map<String, Group> groupMap)
{
this.groups = new LinkedList<>(groupMap.values());
}
@Override
public Iterator<Group> iterator()
{
return groups.iterator();
}
public LinkedList<Group> getGroups()
{
return groups;
}
public void setGroups(LinkedList<Group> groups)
{
this.groups = groups;
}
}

View File

@@ -0,0 +1,50 @@
/*
* Copyright (c) 2020 - present Cloudogu GmbH
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3.
*
* 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 Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
package sonia.scm.group.xml;
import jakarta.xml.bind.annotation.adapters.XmlAdapter;
import sonia.scm.group.Group;
import java.util.Map;
import java.util.TreeMap;
public class XmlGroupMapAdapter
extends XmlAdapter<XmlGroupList, Map<String, Group>>
{
@Override
public XmlGroupList marshal(Map<String, Group> groupMap) throws Exception
{
return new XmlGroupList(groupMap);
}
@Override
public Map<String, Group> unmarshal(XmlGroupList groups) throws Exception
{
Map<String, Group> groupMap = new TreeMap<>();
for (Group group : groups)
{
groupMap.put(group.getName(), group);
}
return groupMap;
}
}

View File

@@ -0,0 +1,83 @@
/*
* Copyright (c) 2020 - present Cloudogu GmbH
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3.
*
* 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 Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
package sonia.scm.repository.xml;
import jakarta.xml.bind.JAXBContext;
import jakarta.xml.bind.JAXBException;
import jakarta.xml.bind.Marshaller;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import sonia.scm.ContextEntry;
import sonia.scm.repository.InternalRepositoryException;
import sonia.scm.repository.Repository;
import sonia.scm.CopyOnWrite;
import sonia.scm.store.file.StoreConstants;
import sonia.scm.update.UpdateStepRepositoryMetadataAccess;
import java.nio.file.Path;
import static sonia.scm.CopyOnWrite.compute;
public class MetadataStore implements UpdateStepRepositoryMetadataAccess<Path> {
private static final Logger LOG = LoggerFactory.getLogger(MetadataStore.class);
private final JAXBContext jaxbContext;
public MetadataStore() {
try {
jaxbContext = JAXBContext.newInstance(Repository.class);
} catch (JAXBException ex) {
throw new IllegalStateException("failed to create jaxb context for repository", ex);
}
}
@Override
public Repository read(Path path) {
LOG.trace("read repository metadata from {}", path);
return compute(() -> {
try {
return (Repository) jaxbContext.createUnmarshaller().unmarshal(resolveDataPath(path).toFile());
} catch (JAXBException | IllegalArgumentException ex) {
throw new InternalRepositoryException(
ContextEntry.ContextBuilder.entity(Path.class, path.toString()).build(), "failed read repository metadata", ex
);
}
}).withLockedFileForRead(path);
}
/**
* Write the repository metadata to the given path.
*/
void write(Path path, Repository repository) {
LOG.trace("write repository metadata of {} to {}", repository, path);
try {
Marshaller marshaller = jaxbContext.createMarshaller();
marshaller.setProperty(Marshaller.JAXB_FORMATTED_OUTPUT, Boolean.TRUE);
CopyOnWrite.withTemporaryFile(
temp -> marshaller.marshal(repository, temp.toFile()),
resolveDataPath(path)
);
} catch (JAXBException ex) {
throw new InternalRepositoryException(repository, "failed write repository metadata", ex);
}
}
private Path resolveDataPath(Path repositoryPath) {
return repositoryPath.resolve(StoreConstants.REPOSITORY_METADATA.concat(StoreConstants.FILE_EXTENSION));
}
}

View File

@@ -0,0 +1,277 @@
/*
* Copyright (c) 2020 - present Cloudogu GmbH
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3.
*
* 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 Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
package sonia.scm.repository.xml;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import org.apache.commons.io.FileUtils;
import org.apache.commons.lang3.event.EventListenerSupport;
import sonia.scm.SCMContextProvider;
import sonia.scm.io.FileSystem;
import sonia.scm.repository.BasicRepositoryLocationResolver;
import sonia.scm.repository.InitialRepositoryLocationResolver;
import sonia.scm.repository.Repository;
import sonia.scm.store.file.StoreConstants;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Clock;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.BiConsumer;
/**
* A Location Resolver for File based Repository Storage.
* <p>
* <b>WARNING:</b> The Locations provided with this class may not be used from the plugins to store any plugin specific files.
* <p>
* Please use the {@link sonia.scm.store.DataStoreFactory } and the {@link sonia.scm.store.DataStore} classes to store data<br>
* Please use the {@link sonia.scm.store.BlobStoreFactory } and the {@link sonia.scm.store.BlobStore} classes to store binary files<br>
* Please use the {@link sonia.scm.store.ConfigurationStoreFactory} and the {@link sonia.scm.store.ConfigurationStore} classes to store configurations
*
* @since 2.0.0
*/
@Singleton
public class PathBasedRepositoryLocationResolver extends BasicRepositoryLocationResolver<Path> {
public static final String STORE_NAME = "repository-paths";
private final SCMContextProvider contextProvider;
private final InitialRepositoryLocationResolver initialRepositoryLocationResolver;
private final FileSystem fileSystem;
private final PathDatabase pathDatabase;
private final Map<String, Path> pathById;
private final Clock clock;
private long creationTime;
private long lastModified;
private EventListenerSupport<MaintenanceCallback> maintenanceCallbacks = EventListenerSupport.create(MaintenanceCallback.class);
@Inject
public PathBasedRepositoryLocationResolver(SCMContextProvider contextProvider, InitialRepositoryLocationResolver initialRepositoryLocationResolver, FileSystem fileSystem) {
this(contextProvider, initialRepositoryLocationResolver, fileSystem, Clock.systemUTC());
}
PathBasedRepositoryLocationResolver(SCMContextProvider contextProvider, InitialRepositoryLocationResolver initialRepositoryLocationResolver, FileSystem fileSystem, Clock clock) {
super(Path.class);
this.contextProvider = contextProvider;
this.initialRepositoryLocationResolver = initialRepositoryLocationResolver;
this.fileSystem = fileSystem;
this.pathById = new ConcurrentHashMap<>();
this.clock = clock;
this.creationTime = clock.millis();
pathDatabase = new PathDatabase(resolveStorePath());
read();
}
@Override
@SuppressWarnings("unchecked")
public <T> RepositoryLocationResolverInstance<T> create(Class<T> type) {
if (type.isAssignableFrom(Path.class)) {
return (RepositoryLocationResolverInstance<T>) new RepositoryLocationResolverInstance<Path>() {
@Override
public Path getLocation(String repositoryId) {
if (pathById.containsKey(repositoryId)) {
return contextProvider.resolve(pathById.get(repositoryId));
} else {
throw new LocationNotFoundException(repositoryId);
}
}
@Override
public Path createLocation(String repositoryId) {
if (pathById.containsKey(repositoryId)) {
throw new IllegalStateException("location for repository " + repositoryId + " already exists");
} else {
return create(repositoryId);
}
}
@Override
public void setLocation(String repositoryId, Path location) {
if (pathById.containsKey(repositoryId)) {
throw new IllegalStateException("location for repository " + repositoryId + " already exists");
} else {
PathBasedRepositoryLocationResolver.this.setLocation(repositoryId, location.toAbsolutePath());
}
}
@Override
public void modifyLocation(String repositoryId, Path newPath) throws RepositoryStorageException {
modifyLocation(repositoryId, newPath, oldPath -> FileUtils.moveDirectory(contextProvider.resolve(oldPath).toFile(), newPath.toFile()));
}
@Override
public void modifyLocationAndKeepOld(String repositoryId, Path newPath) throws RepositoryStorageException {
modifyLocation(repositoryId, newPath, oldPath -> FileUtils.copyDirectory(contextProvider.resolve(oldPath).toFile(), newPath.toFile()));
}
private void modifyLocation(String repositoryId, Path newPath, Modifier modifier) throws RepositoryStorageException {
if (newPath.toFile().exists() && !newPath.toFile().canWrite()) {
throw new RepositoryStorageException("cannot create repository at new path " + newPath + "; path is not writable");
}
maintenanceCallbacks.fire().downForMaintenance(new DownForMaintenanceContext(repositoryId));
Path oldPath = pathById.get(repositoryId);
pathById.remove(repositoryId);
try {
modifier.modify(contextProvider.resolve(oldPath));
} catch (Exception e) {
PathBasedRepositoryLocationResolver.this.setLocation(repositoryId, oldPath);
maintenanceCallbacks.fire().upAfterMaintenance(new UpAfterMaintenanceContext(repositoryId, oldPath));
throw new RepositoryStorageException("could not create repository at new path " + newPath, e);
}
PathBasedRepositoryLocationResolver.this.setLocation(repositoryId, newPath);
maintenanceCallbacks.fire().upAfterMaintenance(new UpAfterMaintenanceContext(repositoryId, newPath));
}
@Override
public void forAllLocations(BiConsumer<String, Path> consumer) {
pathById.forEach((id, path) -> consumer.accept(id, contextProvider.resolve(path)));
}
};
} else {
throw new IllegalArgumentException("type not supported: " + type);
}
}
Path create(Repository repository) {
Path path = initialRepositoryLocationResolver.getPath(repository);
return create(repository.getId(), path);
}
Path create(String repositoryId) {
Path path = initialRepositoryLocationResolver.getPath(repositoryId);
return create(repositoryId, path);
}
private Path create(String repositoryId, Path path) {
if (Files.exists(path)) {
throw new RepositoryStorageException("path " + path + " for repository " + repositoryId + " already exists");
}
setLocation(repositoryId, path);
Path resolvedPath = contextProvider.resolve(path);
try {
fileSystem.create(resolvedPath.toFile());
} catch (Exception e) {
throw new RepositoryStorageException("could not create directory " + path + " for new repository " + repositoryId, e);
}
return resolvedPath;
}
Path remove(String repositoryId) {
Path removedPath = pathById.remove(repositoryId);
writePathDatabase();
return contextProvider.resolve(removedPath);
}
void updateModificationDate() {
this.writePathDatabase();
}
private void writePathDatabase() {
lastModified = clock.millis();
pathDatabase.write(creationTime, lastModified, pathById);
}
private void read() {
Path storePath = resolveStorePath();
// Files.exists is slow on java 8
if (storePath.toFile().exists()) {
pathDatabase.read(this::onLoadDates, this::onLoadRepository);
}
}
private void onLoadDates(long creationTime, long lastModified) {
this.creationTime = creationTime;
this.lastModified = lastModified;
}
public Long getCreationTime() {
return creationTime;
}
public Long getLastModified() {
return lastModified;
}
private void onLoadRepository(String id, Path repositoryPath) {
pathById.put(id, repositoryPath);
}
private Path resolveStorePath() {
return contextProvider.getBaseDirectory()
.toPath()
.resolve(StoreConstants.CONFIG_DIRECTORY_NAME)
.resolve(STORE_NAME.concat(StoreConstants.FILE_EXTENSION));
}
private void setLocation(String repositoryId, Path repositoryBasePath) {
pathById.put(repositoryId, repositoryBasePath);
writePathDatabase();
}
public void refresh() {
this.read();
}
void registerMaintenanceCallback(MaintenanceCallback maintenanceCallback) {
maintenanceCallbacks.addListener(maintenanceCallback);
}
public interface MaintenanceCallback {
default void downForMaintenance(DownForMaintenanceContext context) {}
default void upAfterMaintenance(UpAfterMaintenanceContext context) {}
}
@Getter
@EqualsAndHashCode
public static class DownForMaintenanceContext {
private final String repositoryId;
DownForMaintenanceContext(String repositoryId) {
this.repositoryId = repositoryId;
}
}
@Getter
@EqualsAndHashCode
public static class UpAfterMaintenanceContext {
private final String repositoryId;
private final Path location;
UpAfterMaintenanceContext(String repositoryId, Path location) {
this.repositoryId = repositoryId;
this.location = location;
}
}
private interface Modifier {
void modify(Path oldPath) throws IOException;
}
}

View File

@@ -0,0 +1,170 @@
/*
* Copyright (c) 2020 - present Cloudogu GmbH
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3.
*
* 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 Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
package sonia.scm.repository.xml;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import sonia.scm.ContextEntry;
import sonia.scm.repository.InternalRepositoryException;
import sonia.scm.CopyOnWrite;
import sonia.scm.xml.XmlStreams;
import sonia.scm.xml.XmlStreams.AutoCloseableXMLReader;
import sonia.scm.xml.XmlStreams.AutoCloseableXMLWriter;
import javax.xml.stream.XMLStreamConstants;
import javax.xml.stream.XMLStreamException;
import javax.xml.stream.XMLStreamReader;
import javax.xml.stream.XMLStreamWriter;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Map;
import static sonia.scm.CopyOnWrite.execute;
class PathDatabase {
private static final Logger LOG = LoggerFactory.getLogger(PathDatabase.class);
private static final String ENCODING = "UTF-8";
private static final String VERSION = "1.0";
private static final String ELEMENT_REPOSITORIES = "repositories";
private static final String ATTRIBUTE_CREATION_TIME = "creation-time";
private static final String ATTRIBUTE_LAST_MODIFIED = "last-modified";
private static final String ELEMENT_REPOSITORY = "repository";
private static final String ATTRIBUTE_ID = "id";
private final Path storePath;
PathDatabase(Path storePath){
this.storePath = storePath;
}
void write(long creationTime, long lastModified, Map<String, Path> pathDatabase) {
ensureParentDirectoryExists();
LOG.trace("write repository path database to {}", storePath);
CopyOnWrite.withTemporaryFile(
temp -> {
try (AutoCloseableXMLWriter writer = XmlStreams.createWriter(temp)) {
writer.writeStartDocument(ENCODING, VERSION);
writeRepositoriesStart(writer, creationTime, lastModified);
for (Map.Entry<String, Path> e : pathDatabase.entrySet()) {
writeRepository(writer, e.getKey(), e.getValue());
}
writer.writeEndElement();
writer.writeEndDocument();
} catch (XMLStreamException | IOException ex) {
throw new InternalRepositoryException(
ContextEntry.ContextBuilder.entity(Path.class, storePath.toString()).build(),
"failed to write repository path database",
ex
);
}
},
storePath
);
}
private void ensureParentDirectoryExists() {
Path parent = storePath.getParent();
// Files.exists is slow on java 8
if (!parent.toFile().exists()) {
try {
Files.createDirectories(parent);
} catch (IOException ex) {
throw new InternalRepositoryException(
ContextEntry.ContextBuilder.entity(Path.class, parent.toString()).build(),
"failed to create parent directory",
ex
);
}
}
}
private void writeRepositoriesStart(XMLStreamWriter writer, long creationTime, long lastModified) throws XMLStreamException {
writer.writeStartElement(ELEMENT_REPOSITORIES);
writer.writeAttribute(ATTRIBUTE_CREATION_TIME, String.valueOf(creationTime));
writer.writeAttribute(ATTRIBUTE_LAST_MODIFIED, String.valueOf(lastModified));
}
private void writeRepository(XMLStreamWriter writer, String id, Path value) throws XMLStreamException {
writer.writeStartElement(ELEMENT_REPOSITORY);
writer.writeAttribute(ATTRIBUTE_ID, id);
writer.writeCharacters(value.toString());
writer.writeEndElement();
}
void read(OnRepositories onRepositories, OnRepository onRepository) {
LOG.trace("read repository path database from {}", storePath);
execute(() -> {
try (AutoCloseableXMLReader reader = XmlStreams.createReader(storePath)) {
while (reader.hasNext()) {
int eventType = reader.next();
if (eventType == XMLStreamConstants.START_ELEMENT) {
String element = reader.getLocalName();
if (ELEMENT_REPOSITORIES.equals(element)) {
readRepositories(reader, onRepositories);
} else if (ELEMENT_REPOSITORY.equals(element)) {
readRepository(reader, onRepository);
}
}
}
} catch (XMLStreamException | IOException ex) {
throw new InternalRepositoryException(
ContextEntry.ContextBuilder.entity(Path.class, storePath.toString()).build(),
"failed to read repository path database",
ex
);
}
}).withLockedFileForRead(storePath);
}
private void readRepository(XMLStreamReader reader, OnRepository onRepository) throws XMLStreamException {
String id = reader.getAttributeValue(null, ATTRIBUTE_ID);
Path path = Paths.get(reader.getElementText());
onRepository.handle(id, path);
}
private void readRepositories(XMLStreamReader reader, OnRepositories onRepositories) {
String creationTime = reader.getAttributeValue(null, ATTRIBUTE_CREATION_TIME);
String lastModified = reader.getAttributeValue(null, ATTRIBUTE_LAST_MODIFIED);
onRepositories.handle(Long.parseLong(creationTime), Long.parseLong(lastModified));
}
@FunctionalInterface
interface OnRepositories {
void handle(Long creationTime, Long lastModified);
}
@FunctionalInterface
interface OnRepository {
void handle(String id, Path path);
}
}

View File

@@ -0,0 +1,37 @@
/*
* Copyright (c) 2020 - present Cloudogu GmbH
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3.
*
* 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 Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
package sonia.scm.repository.xml;
import jakarta.inject.Inject;
import sonia.scm.repository.RepositoryLocationResolver;
import java.nio.file.Path;
import java.util.function.BiConsumer;
public class SingleRepositoryUpdateProcessor {
private final RepositoryLocationResolver locationResolver;
@Inject
public SingleRepositoryUpdateProcessor(RepositoryLocationResolver locationResolver) {
this.locationResolver = locationResolver;
}
public void doUpdate(BiConsumer<String, Path> forEachRepository) {
locationResolver.forClass(Path.class).forAllLocations(forEachRepository);
}
}

View File

@@ -0,0 +1,265 @@
/*
* Copyright (c) 2020 - present Cloudogu GmbH
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3.
*
* 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 Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
package sonia.scm.repository.xml;
import com.google.common.collect.ImmutableList;
import com.google.inject.Singleton;
import jakarta.inject.Inject;
import lombok.extern.slf4j.Slf4j;
import sonia.scm.io.FileSystem;
import sonia.scm.repository.InternalRepositoryException;
import sonia.scm.repository.NamespaceAndName;
import sonia.scm.repository.Repository;
import sonia.scm.repository.RepositoryDAO;
import sonia.scm.repository.RepositoryExportingCheck;
import sonia.scm.repository.RepositoryLocationResolver;
import sonia.scm.repository.xml.PathBasedRepositoryLocationResolver.DownForMaintenanceContext;
import sonia.scm.repository.xml.PathBasedRepositoryLocationResolver.UpAfterMaintenanceContext;
import sonia.scm.store.StoreReadOnlyException;
import java.io.IOException;
import java.nio.file.Path;
import java.util.Collection;
import java.util.HashMap;
import java.util.Map;
import java.util.TreeMap;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
import java.util.function.Supplier;
@Singleton
@Slf4j
public class XmlRepositoryDAO implements RepositoryDAO {
private final MetadataStore metadataStore = new MetadataStore();
private final PathBasedRepositoryLocationResolver repositoryLocationResolver;
private final FileSystem fileSystem;
private final RepositoryExportingCheck repositoryExportingCheck;
private final Map<String, Repository> byId;
private final Map<NamespaceAndName, Repository> byNamespaceAndName;
private final ReadWriteLock byNamespaceLock = new ReentrantReadWriteLock();
@Inject
public XmlRepositoryDAO(PathBasedRepositoryLocationResolver repositoryLocationResolver, FileSystem fileSystem, RepositoryExportingCheck repositoryExportingCheck) {
this.repositoryLocationResolver = repositoryLocationResolver;
this.fileSystem = fileSystem;
this.repositoryExportingCheck = repositoryExportingCheck;
this.byId = new HashMap<>();
this.byNamespaceAndName = new TreeMap<>();
init();
this.repositoryLocationResolver.registerMaintenanceCallback(new PathBasedRepositoryLocationResolver.MaintenanceCallback() {
@Override
public void downForMaintenance(DownForMaintenanceContext context) {
Repository repository = byId.get(context.getRepositoryId());
byNamespaceAndName.remove(repository.getNamespaceAndName());
byId.remove(context.getRepositoryId());
}
@Override
public void upAfterMaintenance(UpAfterMaintenanceContext context) {
Repository repository = metadataStore.read(context.getLocation());
byNamespaceAndName.put(repository.getNamespaceAndName(), repository);
byId.put(context.getRepositoryId(), repository);
}
});
}
private void init() {
withWriteLockedMaps(() -> {
RepositoryLocationResolver.RepositoryLocationResolverInstance<Path> pathRepositoryLocationResolverInstance = repositoryLocationResolver.create(Path.class);
pathRepositoryLocationResolverInstance.forAllLocations((repositoryId, repositoryPath) -> {
try {
Repository repository = metadataStore.read(repositoryPath);
if (byNamespaceAndName.containsKey(repository.getNamespaceAndName())) {
log.warn("Duplicate repository found. Adding suffix DUPLICATE to repository {}", repository);
repository.setName(repository.getName() + "-" + repositoryId + "-DUPLICATE");
}
byNamespaceAndName.put(repository.getNamespaceAndName(), repository);
byId.put(repositoryId, repository);
} catch (InternalRepositoryException e) {
log.error("could not read repository metadata from {}", repositoryPath, e);
}
});
});
}
@Override
public String getType() {
return "xml";
}
@Override
public synchronized void add(Repository repository) {
add(repository, repositoryLocationResolver.create(repository));
}
public synchronized void add(Repository repository, Object location) {
if (!(location instanceof Path)) {
throw new IllegalArgumentException("can only handle locations of type " + Path.class.getName() + ", not of type " + location.getClass().getName());
}
Path repositoryPath = (Path) location;
Repository clone = repository.clone();
try {
metadataStore.write(repositoryPath, repository);
} catch (Exception e) {
repositoryLocationResolver.remove(repository.getId());
throw new InternalRepositoryException(repository, "failed to create filesystem", e);
}
withWriteLockedMaps(() -> {
byId.put(repository.getId(), clone);
byNamespaceAndName.put(repository.getNamespaceAndName(), clone);
});
}
@Override
public boolean contains(Repository repository) {
return withReadLockedMaps(() -> byId.containsKey(repository.getId()));
}
@Override
public boolean contains(NamespaceAndName namespaceAndName) {
return withReadLockedMaps(() -> byNamespaceAndName.containsKey(namespaceAndName));
}
@Override
public boolean contains(String id) {
return withReadLockedMaps(() -> byId.containsKey(id));
}
@Override
public Repository get(NamespaceAndName namespaceAndName) {
return withReadLockedMaps(() -> byNamespaceAndName.get(namespaceAndName));
}
@Override
public Repository get(String id) {
return withReadLockedMaps(() -> byId.get(id));
}
@Override
public Collection<Repository> getAll() {
return withReadLockedMaps(() -> ImmutableList.copyOf(byNamespaceAndName.values()));
}
@Override
public void modify(Repository repository) {
Repository clone = repository.clone();
if (mustNotModifyRepository(clone)) {
throw new StoreReadOnlyException(repository);
}
withWriteLockedMaps(() -> {
// remove old namespaceAndName from map, in case of rename
Repository prev = byId.put(clone.getId(), clone);
if (prev != null) {
byNamespaceAndName.remove(prev.getNamespaceAndName());
}
byNamespaceAndName.put(clone.getNamespaceAndName(), clone);
});
Path repositoryPath = repositoryLocationResolver
.create(Path.class)
.getLocation(repository.getId());
repositoryLocationResolver.updateModificationDate();
metadataStore.write(repositoryPath, clone);
}
private boolean mustNotModifyRepository(Repository clone) {
return withReadLockedMaps(() ->
clone.isArchived() && byId.get(clone.getId()).isArchived()
|| repositoryExportingCheck.isExporting(clone)
);
}
@Override
public void delete(Repository repository) {
if (repository.isArchived() || repositoryExportingCheck.isExporting(repository)) {
throw new StoreReadOnlyException(repository);
}
Path path = withWriteLockedMaps(() -> {
Repository prev = byId.remove(repository.getId());
if (prev != null) {
byNamespaceAndName.remove(prev.getNamespaceAndName());
}
return repositoryLocationResolver.remove(repository.getId());
});
try {
fileSystem.destroy(path.toFile());
} catch (IOException e) {
throw new InternalRepositoryException(repository, "failed to destroy filesystem", e);
}
}
@Override
public Long getCreationTime() {
return repositoryLocationResolver.getCreationTime();
}
@Override
public Long getLastModified() {
return repositoryLocationResolver.getLastModified();
}
public void refresh() {
repositoryLocationResolver.refresh();
withWriteLockedMaps(() -> {
byNamespaceAndName.clear();
byId.clear();
});
init();
}
private void withWriteLockedMaps(Runnable runnable) {
Lock lock = byNamespaceLock.writeLock();
lock.lock();
try {
runnable.run();
} finally {
lock.unlock();
}
}
private <T> T withWriteLockedMaps(Supplier<T> runnable) {
Lock lock = byNamespaceLock.writeLock();
lock.lock();
try {
return runnable.get();
} finally {
lock.unlock();
}
}
private <T> T withReadLockedMaps(Supplier<T> runnable) {
Lock lock = byNamespaceLock.readLock();
lock.lock();
try {
return runnable.get();
} finally {
lock.unlock();
}
}
}

View File

@@ -0,0 +1,58 @@
/*
* Copyright (c) 2020 - present Cloudogu GmbH
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3.
*
* 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 Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
package sonia.scm.repository.xml;
import com.google.inject.Inject;
import jakarta.inject.Singleton;
import sonia.scm.repository.RepositoryRole;
import sonia.scm.repository.RepositoryRoleDAO;
import sonia.scm.store.ConfigurationStoreFactory;
import sonia.scm.xml.AbstractXmlDAO;
import java.util.List;
@Singleton
public class XmlRepositoryRoleDAO extends AbstractXmlDAO<RepositoryRole, XmlRepositoryRoleDatabase>
implements RepositoryRoleDAO {
public static final String STORE_NAME = "repositoryRoles";
@Inject
public XmlRepositoryRoleDAO(ConfigurationStoreFactory storeFactory) {
super(storeFactory
.withType(XmlRepositoryRoleDatabase.class)
.withName(STORE_NAME)
.build());
}
@Override
protected RepositoryRole clone(RepositoryRole role)
{
return role.clone();
}
@Override
protected XmlRepositoryRoleDatabase createNewDatabase()
{
return new XmlRepositoryRoleDatabase();
}
@Override
public List<RepositoryRole> getAll() {
return (List<RepositoryRole>) super.getAll();
}
}

View File

@@ -0,0 +1,95 @@
/*
* Copyright (c) 2020 - present Cloudogu GmbH
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3.
*
* 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 Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
package sonia.scm.repository.xml;
import jakarta.xml.bind.annotation.XmlAccessType;
import jakarta.xml.bind.annotation.XmlAccessorType;
import jakarta.xml.bind.annotation.XmlElement;
import jakarta.xml.bind.annotation.XmlRootElement;
import jakarta.xml.bind.annotation.adapters.XmlJavaTypeAdapter;
import sonia.scm.auditlog.AuditEntry;
import sonia.scm.repository.RepositoryRole;
import sonia.scm.xml.XmlDatabase;
import java.util.Collection;
import java.util.Map;
import java.util.TreeMap;
@AuditEntry(ignore = true)
@XmlRootElement(name = "user-db")
@XmlAccessorType(XmlAccessType.FIELD)
public class XmlRepositoryRoleDatabase implements XmlDatabase<RepositoryRole> {
private Long creationTime;
private Long lastModified;
@XmlJavaTypeAdapter(XmlRepositoryRoleMapAdapter.class)
@XmlElement(name = "roles")
private Map<String, RepositoryRole> roleMap = new TreeMap<>();
public XmlRepositoryRoleDatabase() {
long c = System.currentTimeMillis();
creationTime = c;
lastModified = c;
}
@Override
public void add(RepositoryRole role) {
roleMap.put(role.getName(), role);
}
@Override
public boolean contains(String name) {
return roleMap.containsKey(name);
}
@Override
public RepositoryRole remove(String name) {
return roleMap.remove(name);
}
@Override
public Collection<RepositoryRole> values() {
return roleMap.values();
}
@Override
public RepositoryRole get(String name) {
return roleMap.get(name);
}
@Override
public long getCreationTime() {
return creationTime;
}
@Override
public long getLastModified() {
return lastModified;
}
@Override
public void setCreationTime(long creationTime) {
this.creationTime = creationTime;
}
@Override
public void setLastModified(long lastModified) {
this.lastModified = lastModified;
}
}

View File

@@ -0,0 +1,57 @@
/*
* Copyright (c) 2020 - present Cloudogu GmbH
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3.
*
* 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 Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
package sonia.scm.repository.xml;
import jakarta.xml.bind.annotation.XmlAccessType;
import jakarta.xml.bind.annotation.XmlAccessorType;
import jakarta.xml.bind.annotation.XmlElement;
import jakarta.xml.bind.annotation.XmlRootElement;
import sonia.scm.repository.RepositoryRole;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.Map;
@XmlRootElement(name = "roles")
@XmlAccessorType(XmlAccessType.FIELD)
public class XmlRepositoryRoleList implements Iterable<RepositoryRole> {
public XmlRepositoryRoleList() {}
public XmlRepositoryRoleList(Map<String, RepositoryRole> roleMap) {
this.roles = new LinkedList<RepositoryRole>(roleMap.values());
}
@Override
public Iterator<RepositoryRole> iterator()
{
return roles.iterator();
}
public LinkedList<RepositoryRole> getRoles()
{
return roles;
}
public void setRoles(LinkedList<RepositoryRole> roles)
{
this.roles = roles;
}
@XmlElement(name = "role")
private LinkedList<RepositoryRole> roles;
}

View File

@@ -0,0 +1,43 @@
/*
* Copyright (c) 2020 - present Cloudogu GmbH
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3.
*
* 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 Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
package sonia.scm.repository.xml;
import jakarta.xml.bind.annotation.adapters.XmlAdapter;
import sonia.scm.repository.RepositoryRole;
import java.util.Map;
import java.util.TreeMap;
public class XmlRepositoryRoleMapAdapter
extends XmlAdapter<XmlRepositoryRoleList, Map<String, RepositoryRole>> {
@Override
public XmlRepositoryRoleList marshal(Map<String, RepositoryRole> roleMap) {
return new XmlRepositoryRoleList(roleMap);
}
@Override
public Map<String, RepositoryRole> unmarshal(XmlRepositoryRoleList roles) {
Map<String, RepositoryRole> roleMap = new TreeMap<>();
for (RepositoryRole role : roles) {
roleMap.put(role.getName(), role);
}
return roleMap;
}
}

View File

@@ -0,0 +1,47 @@
/*
* Copyright (c) 2020 - present Cloudogu GmbH
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3.
*
* 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 Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
package sonia.scm.store;
import jakarta.inject.Inject;
import sonia.scm.SCMContextProvider;
import sonia.scm.repository.RepositoryLocationResolver;
import sonia.scm.store.file.FileStoreUpdateStepUtil;
import sonia.scm.update.StoreUpdateStepUtilFactory;
public class FileStoreUpdateStepUtilFactory implements StoreUpdateStepUtilFactory {
private final RepositoryLocationResolver locationResolver;
private final SCMContextProvider contextProvider;
private final QueryableStoreFactory queryableStoreFactory;
@Inject
public FileStoreUpdateStepUtilFactory(RepositoryLocationResolver locationResolver, SCMContextProvider contextProvider, QueryableStoreFactory queryableStoreFactory) {
this.locationResolver = locationResolver;
this.contextProvider = contextProvider;
this.queryableStoreFactory = queryableStoreFactory;
}
@Override
public StoreUpdateStepUtil build(StoreType type, StoreParameters parameters) {
return new FileStoreUpdateStepUtil(locationResolver, contextProvider, parameters, type);
}
@Override
public <T> QueryableMaintenanceStore<T> forQueryableType(Class<T> clazz, String... parents) {
return queryableStoreFactory.getForMaintenance(clazz, parents);
}
}

View File

@@ -0,0 +1,42 @@
/*
* Copyright (c) 2020 - present Cloudogu GmbH
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3.
*
* 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 Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
package sonia.scm.store;
import jakarta.inject.Inject;
import sonia.scm.repository.Repository;
import sonia.scm.repository.RepositoryLocationResolver;
import sonia.scm.store.file.FileBasedStoreEntryImporterFactory;
import java.nio.file.Path;
public class RepositoryStoreImporter implements StoreImporter {
private final RepositoryLocationResolver locationResolver;
@Inject
public RepositoryStoreImporter(RepositoryLocationResolver locationResolver) {
this.locationResolver = locationResolver;
}
@Override
public StoreEntryImporterFactory doImport(Repository repository) {
Path storeLocation = locationResolver
.forClass(Path.class)
.getLocation(repository.getId());
return new FileBasedStoreEntryImporterFactory(storeLocation);
}
}

View File

@@ -0,0 +1,137 @@
/*
* Copyright (c) 2020 - present Cloudogu GmbH
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3.
*
* 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 Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
package sonia.scm.store.file;
import com.google.common.annotations.VisibleForTesting;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import sonia.scm.cache.Cache;
import sonia.scm.cache.CacheManager;
import sonia.scm.config.ConfigValue;
import java.io.File;
import java.util.function.Supplier;
@Singleton
class DataFileCache {
private static final String CACHE_NAME = "sonia.cache.dataFileCache";
private static final Logger LOG = LoggerFactory.getLogger(DataFileCache.class);
private static final NoDataFileCacheInstance NO_CACHE = new NoDataFileCacheInstance();
private final Cache<File, Object> cache;
private final boolean cacheEnabled;
@Inject
DataFileCache(
@ConfigValue(key = "cache.dataFile.enabled", defaultValue = "true", description = "Enabled caching for all read files") Boolean cacheEnabled,
CacheManager cacheManager
) {
this(cacheManager.getCache(CACHE_NAME), cacheEnabled);
}
@VisibleForTesting
DataFileCache(Cache<File, Object> cache, boolean cacheEnabled) {
this.cache = cache;
this.cacheEnabled = cacheEnabled;
LOG.debug("data file cache enabled: {}", cacheEnabled);
}
DataFileCacheInstance instanceFor(Class<?> type) {
if (cacheEnabled) {
return new GCacheDataFileCacheInstance(type);
} else {
return NO_CACHE;
}
}
@Override
public String toString() {
long size = cache.keys().stream().map(File::length).reduce(0L, Long::sum);
return String.format("data file cache, %s entries, %s bytes cached", cache.size(), size);
}
interface DataFileCacheInstance {
<T> void put(File file, T item);
<T> T get(File file, Supplier<T> reader);
void remove(File file);
}
private static class NoDataFileCacheInstance implements DataFileCacheInstance {
@Override
public <T> void put(File file, T item) {
// nothing to do without cache
}
@Override
public <T> T get(File file, Supplier<T> reader) {
return reader.get();
}
@Override
public void remove(File file) {
// nothing to do
}
}
private class GCacheDataFileCacheInstance implements DataFileCacheInstance {
private final Class<?> type;
GCacheDataFileCacheInstance(Class<?> type) {
this.type = type;
}
public <T> void put(File file, T item) {
LOG.trace("put '{}' in {}", file, DataFileCache.this);
if (item != null) {
cache.put(file, item);
}
}
public <T> T get(File file, Supplier<T> reader) {
LOG.trace("get of '{}' from {}", file, DataFileCache.this);
File absoluteFile = file.getAbsoluteFile();
if (cache.contains(absoluteFile)) {
T t = (T) cache.get(absoluteFile);
if (t == null || type.isAssignableFrom(t.getClass())) {
return t;
} else {
LOG.info("discarding cached entry with wrong type (expected: {}, got {})", type, t.getClass());
cache.remove(file);
}
}
T item = reader.get();
if (item != null) {
cache.put(absoluteFile, item);
}
return item;
}
public void remove(File file) {
LOG.trace("remove of '{}' from {}", file, DataFileCache.this);
cache.remove(file);
}
}
}

View File

@@ -0,0 +1,84 @@
/*
* Copyright (c) 2020 - present Cloudogu GmbH
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3.
*
* 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 Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
package sonia.scm.store.file;
import jakarta.inject.Inject;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import sonia.scm.SCMContextProvider;
import sonia.scm.repository.RepositoryLocationResolver;
import sonia.scm.update.BlobDirectoryAccess;
import sonia.scm.util.IOUtil;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.stream.Stream;
public class DefaultBlobDirectoryAccess implements BlobDirectoryAccess {
private static final Logger LOG = LoggerFactory.getLogger(DefaultBlobDirectoryAccess.class);
private final SCMContextProvider contextProvider;
private final RepositoryLocationResolver locationResolver;
@Inject
public DefaultBlobDirectoryAccess(SCMContextProvider contextProvider, RepositoryLocationResolver locationResolver) {
this.contextProvider = contextProvider;
this.locationResolver = locationResolver;
}
@Override
public void forBlobDirectories(BlobDirectoryConsumer blobDirectoryConsumer) throws IOException {
Path v1blobDir = computeV1BlobDir();
if (Files.exists(v1blobDir) && Files.isDirectory(v1blobDir)) {
try (Stream<Path> fileStream = Files.list(v1blobDir)) {
fileStream.filter(p -> Files.isDirectory(p)).forEach(p -> {
try {
blobDirectoryConsumer.accept(p);
} catch (IOException e) {
throw new RuntimeException("could not call consumer for blob directory " + p, e);
}
});
}
}
}
@Override
public void moveToRepositoryBlobStore(Path blobDirectory, String newDirectoryName, String repositoryId) throws IOException {
Path repositoryLocation;
try {
repositoryLocation = locationResolver
.forClass(Path.class)
.getLocation(repositoryId);
} catch (IllegalStateException e) {
LOG.info("ignoring blob directory {} because there is no repository location for repository id {}", blobDirectory, repositoryId);
return;
}
Path target = repositoryLocation
.resolve(Store.BLOB.getRepositoryStoreDirectory());
IOUtil.mkdirs(target.toFile());
Path resolvedSourceDirectory = computeV1BlobDir().resolve(blobDirectory);
Path resolvedTargetDirectory = target.resolve(newDirectoryName);
LOG.trace("moving directory {} to {}", resolvedSourceDirectory, resolvedTargetDirectory);
Files.move(resolvedSourceDirectory, resolvedTargetDirectory);
}
private Path computeV1BlobDir() {
return contextProvider.getBaseDirectory().toPath().resolve("var").resolve("blob");
}
}

View File

@@ -0,0 +1,45 @@
/*
* Copyright (c) 2020 - present Cloudogu GmbH
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3.
*
* 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 Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
package sonia.scm.store.file;
import sonia.scm.repository.api.ExportFailedException;
import sonia.scm.store.Exporter;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import static sonia.scm.ContextEntry.ContextBuilder.noContext;
final class ExportCopier {
private ExportCopier() {
}
static void putFileContentIntoStream(Exporter exporter, Path file) {
try (OutputStream stream = exporter.put(file.getFileName().toString(), Files.size(file))) {
Files.copy(file, stream);
} catch (IOException e) {
throw new ExportFailedException(
noContext(),
"Could not copy file to export stream: " + file,
e
);
}
}
}

View File

@@ -0,0 +1,51 @@
/*
* Copyright (c) 2020 - present Cloudogu GmbH
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3.
*
* 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 Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
package sonia.scm.store.file;
import sonia.scm.store.ExportableStore;
import sonia.scm.store.StoreType;
import java.nio.file.Path;
import java.util.Optional;
import java.util.function.Function;
import static java.util.Optional.empty;
import static java.util.Optional.of;
class ExportableBlobFileStore extends ExportableDirectoryBasedFileStore {
private static final String EXCLUDED_EXPORT_STORE = "repository-export";
static final Function<StoreType, Optional<Function<Path, ExportableStore>>> BLOB_FACTORY =
storeType -> storeType == StoreType.BLOB ? of(ExportableBlobFileStore::new) : empty();
ExportableBlobFileStore(Path directory) {
super(directory);
}
@Override
StoreType getStoreType() {
return StoreType.BLOB;
}
boolean shouldIncludeFile(Path file) {
if (getDirectory().toString().endsWith(EXCLUDED_EXPORT_STORE)) {
return false;
}
return file.getFileName().toString().endsWith(".blob");
}
}

View File

@@ -0,0 +1,53 @@
/*
* Copyright (c) 2020 - present Cloudogu GmbH
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3.
*
* 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 Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
package sonia.scm.store.file;
import sonia.scm.store.ExportableStore;
import sonia.scm.store.Exporter;
import sonia.scm.store.StoreEntryMetaData;
import sonia.scm.store.StoreType;
import java.io.IOException;
import java.nio.file.Path;
import java.util.Optional;
import java.util.function.Function;
import static java.util.Optional.empty;
import static java.util.Optional.of;
import static sonia.scm.store.file.ExportCopier.putFileContentIntoStream;
class ExportableConfigEntryFileStore implements ExportableStore {
private final Path file;
static final Function<StoreType, Optional<Function<Path, ExportableStore>>> CONFIG_ENTRY_FACTORY =
storeType -> storeType == StoreType.CONFIG_ENTRY ? of(ExportableConfigEntryFileStore::new) : empty();
ExportableConfigEntryFileStore(Path file) {
this.file = file;
}
@Override
public StoreEntryMetaData getMetaData() {
return new StoreEntryMetaData(StoreType.CONFIG_ENTRY, file.getFileName().toString());
}
@Override
public void export(Exporter exporter) throws IOException {
putFileContentIntoStream(exporter, file);
}
}

View File

@@ -0,0 +1,53 @@
/*
* Copyright (c) 2020 - present Cloudogu GmbH
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3.
*
* 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 Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
package sonia.scm.store.file;
import sonia.scm.store.ExportableStore;
import sonia.scm.store.Exporter;
import sonia.scm.store.StoreEntryMetaData;
import sonia.scm.store.StoreType;
import java.io.IOException;
import java.nio.file.Path;
import java.util.Optional;
import java.util.function.Function;
import static java.util.Optional.empty;
import static java.util.Optional.of;
import static sonia.scm.store.file.ExportCopier.putFileContentIntoStream;
class ExportableConfigFileStore implements ExportableStore {
private final Path file;
static final Function<StoreType, Optional<Function<Path, ExportableStore>>> CONFIG_FACTORY =
storeType -> storeType == StoreType.CONFIG ? of(ExportableConfigFileStore::new) : empty();
ExportableConfigFileStore(Path file) {
this.file = file;
}
@Override
public StoreEntryMetaData getMetaData() {
return new StoreEntryMetaData(StoreType.CONFIG, file.getFileName().toString());
}
@Override
public void export(Exporter exporter) throws IOException {
putFileContentIntoStream(exporter, file);
}
}

View File

@@ -0,0 +1,46 @@
/*
* Copyright (c) 2020 - present Cloudogu GmbH
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3.
*
* 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 Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
package sonia.scm.store.file;
import sonia.scm.store.ExportableStore;
import sonia.scm.store.StoreType;
import java.nio.file.Path;
import java.util.Optional;
import java.util.function.Function;
import static java.util.Optional.empty;
import static java.util.Optional.of;
class ExportableDataFileStore extends ExportableDirectoryBasedFileStore {
static final Function<StoreType, Optional<Function<Path, ExportableStore>>> DATA_FACTORY =
storeType -> storeType == StoreType.DATA ? of(ExportableDataFileStore::new) : empty();
ExportableDataFileStore(Path directory) {
super(directory);
}
@Override
StoreType getStoreType() {
return StoreType.DATA;
}
boolean shouldIncludeFile(Path file) {
return file.getFileName().toString().endsWith(".xml");
}
}

View File

@@ -0,0 +1,76 @@
/*
* Copyright (c) 2020 - present Cloudogu GmbH
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3.
*
* 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 Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
package sonia.scm.store.file;
import sonia.scm.repository.api.ExportFailedException;
import sonia.scm.store.ExportableStore;
import sonia.scm.store.Exporter;
import sonia.scm.store.StoreEntryMetaData;
import sonia.scm.store.StoreType;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.stream.Stream;
import static sonia.scm.ContextEntry.ContextBuilder.noContext;
import static sonia.scm.store.file.ExportCopier.putFileContentIntoStream;
abstract class ExportableDirectoryBasedFileStore implements ExportableStore {
private final Path directory;
ExportableDirectoryBasedFileStore(Path directory) {
this.directory = directory;
}
@Override
public StoreEntryMetaData getMetaData() {
return new StoreEntryMetaData(getStoreType(), directory.getFileName().toString());
}
abstract StoreType getStoreType();
abstract boolean shouldIncludeFile(Path file);
@Override
public void export(Exporter exporter) throws IOException {
exportDirectoryEntries(exporter, directory);
}
private void exportDirectoryEntries(Exporter exporter, Path directory) {
try (Stream<Path> fileList = Files.list(directory)) {
fileList.forEach(fileOrDir -> exportIfRelevant(exporter, fileOrDir));
} catch (IOException e) {
throw new ExportFailedException(
noContext(),
"Could not read directory " + directory,
e
);
}
}
private void exportIfRelevant(Exporter exporter, Path fileOrDir) {
if (!Files.isDirectory(fileOrDir) && shouldIncludeFile(fileOrDir)) {
putFileContentIntoStream(exporter, fileOrDir);
}
}
protected Path getDirectory() {
return directory;
}
}

View File

@@ -0,0 +1,132 @@
/*
* Copyright (c) 2020 - present Cloudogu GmbH
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3.
*
* 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 Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
package sonia.scm.store.file;
import com.google.common.base.Preconditions;
import com.google.common.base.Strings;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import sonia.scm.store.MultiEntryStore;
import sonia.scm.store.StoreException;
import sonia.scm.store.StoreReadOnlyException;
import java.io.File;
abstract class FileBasedStore<T> implements MultiEntryStore<T> {
private static final Logger logger =
LoggerFactory.getLogger(FileBasedStore.class);
public FileBasedStore(File directory, String suffix, boolean readOnly)
{
this.directory = directory;
this.suffix = suffix;
this.readOnly = readOnly;
}
protected abstract T read(File file);
@Override
public void clear()
{
logger.debug("clear store");
for (File file : directory.listFiles())
{
remove(file);
}
}
@Override
public void remove(String id)
{
Preconditions.checkArgument(!Strings.isNullOrEmpty(id),
"id argument is required");
logger.debug("try to delete store entry with id {}", id);
File file = getFile(id);
remove(file);
}
@Override
public T get(String id)
{
Preconditions.checkArgument(!Strings.isNullOrEmpty(id),
"id argument is required");
logger.trace("try to retrieve item with id {}", id);
File file = getFile(id);
return read(file);
}
protected void remove(File file)
{
logger.trace("delete store entry {}", file);
assertNotReadOnly();
if (file.exists() &&!file.delete())
{
throw new StoreException(
"could not delete store entry ".concat(file.getPath()));
}
}
protected File getFile(String id)
{
Preconditions.checkArgument(!Strings.isNullOrEmpty(id),
"id argument is required");
return new File(directory, id.concat(suffix));
}
protected String getId(File file)
{
String name = file.getName();
return name.substring(0, name.length() - suffix.length());
}
protected void assertNotReadOnly() {
if (readOnly) {
throw new StoreReadOnlyException(directory.getAbsoluteFile().toString());
}
}
//~--- fields ---------------------------------------------------------------
protected File directory;
private final String suffix;
private final boolean readOnly;
}

View File

@@ -0,0 +1,57 @@
/*
* Copyright (c) 2020 - present Cloudogu GmbH
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3.
*
* 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 Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
package sonia.scm.store.file;
import com.google.common.annotations.VisibleForTesting;
import sonia.scm.ContextEntry;
import sonia.scm.repository.api.ImportFailedException;
import sonia.scm.store.StoreEntryImporter;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import static java.nio.file.StandardCopyOption.REPLACE_EXISTING;
class FileBasedStoreEntryImporter implements StoreEntryImporter {
private final Path directory;
FileBasedStoreEntryImporter(Path directory) {
this.directory = directory;
}
@VisibleForTesting
Path getDirectory() {
return this.directory;
}
@Override
public void importEntry(String name, InputStream stream) {
Path filePath = directory.resolve(name);
try {
Files.copy(stream, filePath, REPLACE_EXISTING);
} catch (IOException e) {
throw new ImportFailedException(
ContextEntry.ContextBuilder.noContext(),
String.format("Could not import file %s for store %s", name, directory.toString()),
e
);
}
}
}

View File

@@ -0,0 +1,69 @@
/*
* Copyright (c) 2020 - present Cloudogu GmbH
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3.
*
* 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 Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
package sonia.scm.store.file;
import sonia.scm.ContextEntry;
import sonia.scm.repository.api.ImportFailedException;
import sonia.scm.store.StoreEntryImporter;
import sonia.scm.store.StoreEntryImporterFactory;
import sonia.scm.store.StoreEntryMetaData;
import sonia.scm.store.StoreType;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
public class FileBasedStoreEntryImporterFactory implements StoreEntryImporterFactory {
private final Path directory;
public FileBasedStoreEntryImporterFactory(Path directory) {
this.directory = directory;
}
@Override
public StoreEntryImporter importStore(StoreEntryMetaData metaData) {
StoreType storeType = metaData.getType();
String storeName = metaData.getName();
Path storeDirectory = directory.resolve(Store.STORE_DIRECTORY);
try {
storeDirectory = storeDirectory.resolve(resolveFilePath(storeType.getValue(), storeName));
Files.createDirectories(storeDirectory);
if (!Files.exists(storeDirectory)) {
throw new ImportFailedException(
ContextEntry.ContextBuilder.noContext(),
String.format("Could not create store for type %s and name %s", storeType, storeName)
);
}
return new FileBasedStoreEntryImporter(storeDirectory);
} catch (IOException e) {
throw new ImportFailedException(
ContextEntry.ContextBuilder.noContext(),
String.format("Could not create store directory %s for type %s and name %s", storeDirectory, storeType, storeName)
);
}
}
private Path resolveFilePath(String type, String name) {
if (name == null || name.isEmpty()) {
return Paths.get(type);
}
return Paths.get(type, name);
}
}

View File

@@ -0,0 +1,113 @@
/*
* Copyright (c) 2020 - present Cloudogu GmbH
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3.
*
* 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 Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
package sonia.scm.store.file;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import sonia.scm.SCMContextProvider;
import sonia.scm.repository.RepositoryLocationResolver;
import sonia.scm.repository.RepositoryReadOnlyChecker;
import sonia.scm.store.StoreParameters;
import sonia.scm.store.TypedStoreParameters;
import sonia.scm.util.IOUtil;
import java.io.File;
import java.nio.file.Path;
/**
* Abstract store factory for file based stores.
*
*/
abstract class FileBasedStoreFactory {
private static final String NAMESPACES_DIR = "namespaces";
private static final Logger LOG = LoggerFactory.getLogger(FileBasedStoreFactory.class);
private final SCMContextProvider contextProvider;
private final RepositoryLocationResolver repositoryLocationResolver;
private final Store store;
private final RepositoryReadOnlyChecker readOnlyChecker;
protected FileBasedStoreFactory(SCMContextProvider contextProvider, RepositoryLocationResolver repositoryLocationResolver, Store store, RepositoryReadOnlyChecker readOnlyChecker) {
this.contextProvider = contextProvider;
this.repositoryLocationResolver = repositoryLocationResolver;
this.store = store;
this.readOnlyChecker = readOnlyChecker;
}
protected File getStoreLocation(StoreParameters storeParameters) {
return getStoreLocation(storeParameters.getName(), null, storeParameters.getRepositoryId(), storeParameters.getNamespace());
}
protected File getStoreLocation(TypedStoreParameters<?> storeParameters) {
return getStoreLocation(storeParameters.getName(), storeParameters.getType(), storeParameters.getRepositoryId(), storeParameters.getNamespace());
}
protected File getStoreLocation(String name, Class<?> type, String repositoryId, String namespace) {
File storeDirectory;
if (namespace != null) {
LOG.debug("create store with type: {}, name: {} and namespace: {}", type, name, namespace);
storeDirectory = this.getNamespaceStoreDirectory(store, namespace);
} else if (repositoryId != null) {
LOG.debug("create store with type: {}, name: {} and repository id: {}", type, name, repositoryId);
storeDirectory = this.getStoreDirectory(store, repositoryId);
} else {
LOG.debug("create store with type: {} and name: {} ", type, name);
storeDirectory = this.getStoreDirectory(store);
}
IOUtil.mkdirs(storeDirectory);
return new File(storeDirectory, name);
}
protected boolean mustBeReadOnly(StoreParameters storeParameters) {
return storeParameters.getRepositoryId() != null && readOnlyChecker.isReadOnly(storeParameters.getRepositoryId());
}
/**
* Get the store directory of a specific repository
*
* @param store the type of the store
* @param repositoryId the id of the repossitory
* @return the store directory of a specific repository
*/
private File getStoreDirectory(Store store, String repositoryId) {
return new File(repositoryLocationResolver.forClass(Path.class).getLocation(repositoryId).toFile(), store.getRepositoryStoreDirectory());
}
/**
* Get the store directory of a specific namespace
*
* @param store the type of the store
* @param namespace the name of the namespace
* @return the store directory of a specific namespace
*/
private File getNamespaceStoreDirectory(Store store, String namespace) {
return new File(contextProvider.getBaseDirectory().toPath().resolve(NAMESPACES_DIR).resolve(namespace).toString(), store.getNamespaceStoreDirectory());
}
/**
* Get the global store directory
*
* @param store the type of the store
* @return the global store directory
*/
private File getStoreDirectory(Store store) {
return new File(contextProvider.getBaseDirectory(), store.getGlobalStoreDirectory());
}
}

View File

@@ -0,0 +1,74 @@
/*
* Copyright (c) 2020 - present Cloudogu GmbH
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3.
*
* 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 Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
package sonia.scm.store.file;
import sonia.scm.store.Blob;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
/**
* File base implementation of {@link Blob}.
*
*/
final class FileBlob implements Blob {
private final String id;
private final File file;
FileBlob(String id, File file) {
this.id = id;
this.file = file;
}
@Override
public void commit() throws IOException {
// nothing to do
}
@Override
public String getId() {
return id;
}
@Override
public InputStream getInputStream() throws FileNotFoundException {
return new FileInputStream(file);
}
@Override
public OutputStream getOutputStream() throws IOException {
return new FileOutputStream(file);
}
@Override
public long getSize() {
if (this.file.isFile()) {
return this.file.length();
} else {
//to sum up all other cases, in which we cannot determine a size
return -1;
}
}
}

View File

@@ -0,0 +1,122 @@
/*
* Copyright (c) 2020 - present Cloudogu GmbH
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3.
*
* 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 Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
package sonia.scm.store.file;
import com.google.common.base.Preconditions;
import com.google.common.base.Strings;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableList.Builder;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import sonia.scm.security.KeyGenerator;
import sonia.scm.store.Blob;
import sonia.scm.store.BlobStore;
import sonia.scm.store.EntryAlreadyExistsStoreException;
import sonia.scm.store.StoreException;
import java.io.File;
import java.io.IOException;
import java.util.List;
/**
* File based implementation of {@link BlobStore}.
*
*/
class FileBlobStore extends FileBasedStore<Blob> implements BlobStore {
private static final Logger LOG
= LoggerFactory.getLogger(FileBlobStore.class);
private static final String SUFFIX = ".blob";
private final KeyGenerator keyGenerator;
FileBlobStore(KeyGenerator keyGenerator, File directory, boolean readOnly) {
super(directory, SUFFIX, readOnly);
this.keyGenerator = keyGenerator;
}
@Override
public Blob create() {
return create(keyGenerator.createKey());
}
@Override
public Blob create(String id) {
Preconditions.checkArgument(!Strings.isNullOrEmpty(id),
"id argument is required");
LOG.debug("create new blob with id {}", id);
assertNotReadOnly();
File file = getFile(id);
try {
if (file.exists()) {
throw new EntryAlreadyExistsStoreException(
"blob with id ".concat(id).concat(" allready exists"));
}
else if (!file.createNewFile()) {
throw new StoreException("could not create blob for id ".concat(id));
}
}
catch (IOException ex) {
throw new StoreException("could not create blob for id ".concat(id), ex);
}
return new FileBlob(id, file);
}
@Override
public void remove(Blob blob) {
assertNotReadOnly();
Preconditions.checkNotNull(blob, "blob argument is required");
remove(blob.getId());
}
@Override
public List<Blob> getAll() {
LOG.trace("get all items from data store");
Builder<Blob> builder = ImmutableList.builder();
for (File file : directory.listFiles()) {
builder.add(read(file));
}
return builder.build();
}
@Override
protected FileBlob read(File file) {
FileBlob blob = null;
if (file.exists()) {
String id = getId(file);
blob = new FileBlob(id, file);
}
return blob;
}
}

View File

@@ -0,0 +1,55 @@
/*
* Copyright (c) 2020 - present Cloudogu GmbH
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3.
*
* 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 Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
package sonia.scm.store.file;
import com.google.inject.Inject;
import com.google.inject.Singleton;
import sonia.scm.SCMContextProvider;
import sonia.scm.repository.RepositoryLocationResolver;
import sonia.scm.repository.RepositoryReadOnlyChecker;
import sonia.scm.security.KeyGenerator;
import sonia.scm.store.BlobStore;
import sonia.scm.store.BlobStoreFactory;
import sonia.scm.store.StoreParameters;
import sonia.scm.util.IOUtil;
import java.io.File;
/**
* File based store factory.
*
*/
@Singleton
public class FileBlobStoreFactory extends FileBasedStoreFactory implements BlobStoreFactory {
private final KeyGenerator keyGenerator;
@Inject
public FileBlobStoreFactory(SCMContextProvider contextProvider , RepositoryLocationResolver repositoryLocationResolver, KeyGenerator keyGenerator, RepositoryReadOnlyChecker readOnlyChecker) {
super(contextProvider, repositoryLocationResolver, Store.BLOB, readOnlyChecker);
this.keyGenerator = keyGenerator;
}
@Override
@SuppressWarnings("unchecked")
public BlobStore getStore(StoreParameters storeParameters) {
File storeLocation = getStoreLocation(storeParameters);
IOUtil.mkdirs(storeLocation);
return new FileBlobStore(keyGenerator, storeLocation, mustBeReadOnly(storeParameters));
}
}

View File

@@ -0,0 +1,62 @@
/*
* Copyright (c) 2020 - present Cloudogu GmbH
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3.
*
* 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 Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
package sonia.scm.store.file;
import sonia.scm.migration.UpdateException;
import sonia.scm.repository.Repository;
import sonia.scm.repository.RepositoryLocationResolver;
import sonia.scm.update.NamespaceUpdateIterator;
import jakarta.inject.Inject;
import jakarta.xml.bind.JAXBContext;
import jakarta.xml.bind.JAXBException;
import java.nio.file.Path;
import java.util.Collection;
import java.util.HashSet;
import java.util.function.Consumer;
public class FileNamespaceUpdateIterator implements NamespaceUpdateIterator {
private final RepositoryLocationResolver locationResolver;
private final JAXBContext jaxbContext;
@Inject
public FileNamespaceUpdateIterator(RepositoryLocationResolver locationResolver) {
this.locationResolver = locationResolver;
try {
jaxbContext = JAXBContext.newInstance(Repository.class);
} catch (JAXBException ex) {
throw new IllegalStateException("failed to create jaxb context for repository", ex);
}
}
@Override
public void forEachNamespace(Consumer<String> namespaceConsumer) {
Collection<String> namespaces = new HashSet<>();
locationResolver
.forClass(Path.class)
.forAllLocations((repositoryId, path) -> {
try {
Repository metadata = (Repository) jaxbContext.createUnmarshaller().unmarshal(path.resolve("metadata.xml").toFile());
namespaces.add(metadata.getNamespace());
} catch (JAXBException e) {
throw new UpdateException("Failed to read metadata for repository " + repositoryId, e);
}
});
namespaces.forEach(namespaceConsumer);
}
}

View File

@@ -0,0 +1,41 @@
/*
* Copyright (c) 2020 - present Cloudogu GmbH
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3.
*
* 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 Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
package sonia.scm.store.file;
import jakarta.inject.Inject;
import sonia.scm.repository.RepositoryLocationResolver;
import sonia.scm.update.RepositoryUpdateIterator;
import java.nio.file.Path;
import java.util.function.Consumer;
public class FileRepositoryUpdateIterator implements RepositoryUpdateIterator {
private final RepositoryLocationResolver locationResolver;
@Inject
public FileRepositoryUpdateIterator(RepositoryLocationResolver locationResolver) {
this.locationResolver = locationResolver;
}
@Override
public void forEachRepository(Consumer<String> repositoryIdConsumer) {
locationResolver
.forClass(Path.class)
.forAllLocations((repositoryId, path) -> repositoryIdConsumer.accept(repositoryId));
}
}

View File

@@ -0,0 +1,146 @@
/*
* Copyright (c) 2020 - present Cloudogu GmbH
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3.
*
* 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 Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
package sonia.scm.store.file;
import jakarta.inject.Inject;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import sonia.scm.repository.Repository;
import sonia.scm.repository.RepositoryLocationResolver;
import sonia.scm.repository.api.ExportFailedException;
import sonia.scm.store.ExportableStore;
import sonia.scm.store.StoreExporter;
import sonia.scm.store.StoreType;
import sonia.scm.xml.XmlStreams;
import sonia.scm.xml.XmlStreams.AutoCloseableXMLReader;
import javax.xml.stream.XMLStreamException;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
import java.util.function.Function;
import java.util.stream.Stream;
import static java.util.Arrays.asList;
import static java.util.Collections.emptyList;
import static sonia.scm.ContextEntry.ContextBuilder.noContext;
import static sonia.scm.store.file.ExportableBlobFileStore.BLOB_FACTORY;
import static sonia.scm.store.file.ExportableConfigEntryFileStore.CONFIG_ENTRY_FACTORY;
import static sonia.scm.store.file.ExportableConfigFileStore.CONFIG_FACTORY;
import static sonia.scm.store.file.ExportableDataFileStore.DATA_FACTORY;
public class FileStoreExporter implements StoreExporter {
private static final Collection<Function<StoreType, Optional<Function<Path, ExportableStore>>>> STORE_FACTORIES =
asList(DATA_FACTORY, BLOB_FACTORY, CONFIG_FACTORY, CONFIG_ENTRY_FACTORY);
private static final Logger LOG = LoggerFactory.getLogger(FileStoreExporter.class);
private final RepositoryLocationResolver locationResolver;
@Inject
public FileStoreExporter(RepositoryLocationResolver locationResolver) {
this.locationResolver = locationResolver;
}
@Override
public List<ExportableStore> listExportableStores(Repository repository) {
List<ExportableStore> exportableStores = new ArrayList<>();
Path storeDirectory = resolveStoreDirectory(repository);
if (!Files.exists(storeDirectory)) {
return emptyList();
}
try (Stream<Path> storeTypeDirectories = Files.list(storeDirectory)) {
storeTypeDirectories.forEach(storeTypeDirectory ->
exportStoreTypeDirectories(exportableStores, storeTypeDirectory)
);
} catch (IOException e) {
throw new ExportFailedException(
noContext(),
"Could not list content of directory " + storeDirectory,
e
);
}
return exportableStores;
}
private Path resolveStoreDirectory(Repository repository) {
return locationResolver
.forClass(Path.class)
.getLocation(repository.getId())
.resolve(Store.STORE_DIRECTORY);
}
private void exportStoreTypeDirectories(List<ExportableStore> exportableStores, Path storeTypeDirectory) {
try (Stream<Path> storeDirectories = Files.list(storeTypeDirectory)) {
storeDirectories.forEach(storeDirectory ->
getStoreFor(storeDirectory).ifPresent(exportableStores::add)
);
} catch (IOException e) {
throw new ExportFailedException(
noContext(),
"Could not list content of directory " + storeTypeDirectory,
e
);
}
}
private Optional<ExportableStore> getStoreFor(Path storePath) {
return STORE_FACTORIES
.stream()
.map(factory -> factory.apply(getEnumForValue(storePath)))
.filter(Optional::isPresent)
.map(Optional::get)
.findFirst()
.map(f -> f.apply(storePath));
}
private StoreType getEnumForValue(Path storePath) {
if (Files.isDirectory(storePath)) {
for (StoreType type : StoreType.values()) {
if (type.getValue().equals(storePath.getParent().getFileName().toString())) {
return type;
}
}
} else if (storePath.toString().endsWith(".xml")) {
return determineConfigType(storePath);
} else {
LOG.info("ignoring unknown file '{}' in export", storePath);
}
return null;
}
private StoreType determineConfigType(Path storePath) {
try (AutoCloseableXMLReader reader = XmlStreams.createReader(storePath)) {
reader.nextTag();
if (
"configuration".equals(reader.getLocalName())
&& "config-entry".equals(reader.getAttributeValue(0))
&& "type".equals(reader.getAttributeName(0).getLocalPart())) {
return StoreType.CONFIG_ENTRY;
} else {
return StoreType.CONFIG;
}
} catch (XMLStreamException | IOException e) {
throw new ExportFailedException(noContext(), "Failed to read store file " + storePath, e);
}
}
}

View File

@@ -0,0 +1,80 @@
/*
* Copyright (c) 2020 - present Cloudogu GmbH
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3.
*
* 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 Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
package sonia.scm.store.file;
import sonia.scm.SCMContextProvider;
import sonia.scm.migration.UpdateException;
import sonia.scm.repository.RepositoryLocationResolver;
import sonia.scm.store.StoreParameters;
import sonia.scm.store.StoreType;
import sonia.scm.update.StoreUpdateStepUtilFactory;
import sonia.scm.util.IOUtil;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
public class FileStoreUpdateStepUtil implements StoreUpdateStepUtilFactory.StoreUpdateStepUtil {
private final RepositoryLocationResolver locationResolver;
private final SCMContextProvider contextProvider;
private final StoreParameters parameters;
private final StoreType type;
public FileStoreUpdateStepUtil(RepositoryLocationResolver locationResolver, SCMContextProvider contextProvider, StoreParameters parameters, StoreType type) {
this.locationResolver = locationResolver;
this.contextProvider = contextProvider;
this.parameters = parameters;
this.type = type;
}
@Override
public void renameStore(String newName) {
Path oldStorePath = resolveBasePath().resolve(parameters.getName());
if (Files.exists(oldStorePath)) {
Path newStorePath = resolveBasePath().resolve(newName);
try {
Files.move(oldStorePath, newStorePath);
} catch (IOException e) {
throw new UpdateException(String.format("Could not move store path %s to %s", oldStorePath, newStorePath), e);
}
}
}
@Override
public void deleteStore() {
Path oldStorePath = resolveBasePath().resolve(parameters.getName());
IOUtil.deleteSilently(oldStorePath.toFile());
}
private Path resolveBasePath() {
Path basePath;
if (parameters.getRepositoryId() != null) {
basePath = locationResolver.forClass(Path.class).getLocation(parameters.getRepositoryId());
} else {
basePath = contextProvider.getBaseDirectory().toPath();
}
Path storeBasePath;
if (parameters.getRepositoryId() == null) {
storeBasePath = basePath.resolve(Store.forStoreType(type).getGlobalStoreDirectory());
} else {
storeBasePath = basePath.resolve(Store.forStoreType(type).getRepositoryStoreDirectory());
}
return storeBasePath;
}
}

View File

@@ -0,0 +1,214 @@
/*
* Copyright (c) 2020 - present Cloudogu GmbH
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3.
*
* 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 Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
package sonia.scm.store.file;
import com.google.common.collect.Maps;
import jakarta.xml.bind.JAXBElement;
import jakarta.xml.bind.Marshaller;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import sonia.scm.CopyOnWrite;
import sonia.scm.security.KeyGenerator;
import sonia.scm.store.ConfigurationEntryStore;
import sonia.scm.xml.XmlStreams;
import sonia.scm.xml.XmlStreams.AutoCloseableXMLReader;
import sonia.scm.xml.XmlStreams.AutoCloseableXMLWriter;
import javax.xml.namespace.QName;
import java.io.File;
import java.util.Collections;
import java.util.Map;
import java.util.Map.Entry;
import static javax.xml.stream.XMLStreamConstants.END_ELEMENT;
import static sonia.scm.CopyOnWrite.execute;
class JAXBConfigurationEntryStore<V> implements ConfigurationEntryStore<V> {
private static final String TAG_CONFIGURATION = "configuration";
private static final String TAG_ENTRY = "entry";
private static final String TAG_KEY = "key";
private static final String TAG_VALUE = "value";
private static final Logger LOG = LoggerFactory.getLogger(JAXBConfigurationEntryStore.class);
private final File file;
private final KeyGenerator keyGenerator;
private final Class<V> type;
private final TypedStoreContext<V> context;
private final Map<String, V> entries = Maps.newHashMap();
JAXBConfigurationEntryStore(File file, KeyGenerator keyGenerator, Class<V> type, TypedStoreContext<V> context) {
this.file = file;
this.keyGenerator = keyGenerator;
this.type = type;
this.context = context;
// initial load
execute(() -> {
if (file.exists()) {
load();
}
}).withLockedFileForRead(file);
}
@Override
public void clear() {
LOG.debug("clear configuration store");
execute(() -> {
entries.clear();
store();
}).withLockedFileForWrite(file);
}
@Override
public String put(V item) {
String id = keyGenerator.createKey();
put(id, item);
return id;
}
@Override
public void put(String id, V item) {
LOG.debug("put item {} to configuration store", id);
execute(() -> {
entries.put(id, item);
store();
}).withLockedFileForWrite(file);
}
@Override
public void remove(String id) {
LOG.debug("remove item {} from configuration store", id);
execute(() -> {
entries.remove(id);
store();
}).withLockedFileForWrite(file);
}
@Override
public V get(String id) {
LOG.trace("get item {} from configuration store", id);
return entries.get(id);
}
@Override
public Map<String, V> getAll() {
LOG.trace("get all items from configuration store");
return Collections.unmodifiableMap(entries);
}
private void load() {
LOG.debug("load configuration from {}", file);
execute(() ->
context.withUnmarshaller(u -> {
try (AutoCloseableXMLReader reader = XmlStreams.createReader(file)) {
// configuration
reader.nextTag();
// entry start
reader.nextTag();
while (reader.isStartElement() && reader.getLocalName().equals(TAG_ENTRY)) {
// read key
reader.nextTag();
String key = reader.getElementText();
// read value
reader.nextTag();
JAXBElement<V> element = u.unmarshal(reader, type);
if (!element.isNil()) {
V v = element.getValue();
LOG.trace("add element {} to configuration entry store", v);
entries.put(key, v);
} else {
LOG.warn("could not unmarshall object of entry store");
}
// closed or new entry tag
if (reader.nextTag() == END_ELEMENT) {
// fixed format, start new entry
reader.nextTag();
}
}
}
})).withLockedFileForRead(file);
}
private void store() {
LOG.debug("store configuration to {}", file);
context.withMarshaller(m -> {
m.setProperty(Marshaller.JAXB_FRAGMENT, Boolean.TRUE);
CopyOnWrite.withTemporaryFile(
temp -> {
try (AutoCloseableXMLWriter writer = XmlStreams.createWriter(temp)) {
writer.writeStartDocument();
// configuration start
writer.writeStartElement(TAG_CONFIGURATION);
writer.writeAttribute("type", "config-entry");
for (Entry<String, V> e : entries.entrySet()) {
// entry start
writer.writeStartElement(TAG_ENTRY);
// key start
writer.writeStartElement(TAG_KEY);
writer.writeCharacters(e.getKey());
// key end
writer.writeEndElement();
// value
JAXBElement<V> je = new JAXBElement<>(QName.valueOf(TAG_VALUE), type,
e.getValue());
m.marshal(je, writer);
// entry end
writer.writeEndElement();
}
// configuration end
writer.writeEndElement();
writer.writeEndDocument();
}
},
file.toPath()
);
});
}
}

View File

@@ -0,0 +1,68 @@
/*
* Copyright (c) 2020 - present Cloudogu GmbH
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3.
*
* 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 Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
package sonia.scm.store.file;
import com.google.inject.Inject;
import com.google.inject.Singleton;
import sonia.scm.SCMContextProvider;
import sonia.scm.repository.RepositoryLocationResolver;
import sonia.scm.repository.RepositoryReadOnlyChecker;
import sonia.scm.security.KeyGenerator;
import sonia.scm.store.ConfigurationEntryStore;
import sonia.scm.store.ConfigurationEntryStoreFactory;
import sonia.scm.store.TypedStoreParameters;
@Singleton
public class JAXBConfigurationEntryStoreFactory extends FileBasedStoreFactory
implements ConfigurationEntryStoreFactory {
private final KeyGenerator keyGenerator;
private final StoreCache<ConfigurationEntryStore<?>> storeCache;
@Inject
public JAXBConfigurationEntryStoreFactory(
SCMContextProvider contextProvider,
RepositoryLocationResolver repositoryLocationResolver,
KeyGenerator keyGenerator,
RepositoryReadOnlyChecker readOnlyChecker,
StoreCacheFactory storeCacheFactory
) {
super(contextProvider, repositoryLocationResolver, Store.CONFIG, readOnlyChecker);
this.keyGenerator = keyGenerator;
this.storeCache = storeCacheFactory.createStoreCache(this::createStore);
}
@Override
@SuppressWarnings("unchecked")
public <T> ConfigurationEntryStore<T> getStore(TypedStoreParameters<T> storeParameters) {
return (ConfigurationEntryStore<T>) storeCache.getStore(storeParameters);
}
private <T> ConfigurationEntryStore<T> createStore(TypedStoreParameters<T> storeParameters) {
return new JAXBConfigurationEntryStore<>(
getStoreLocation(storeParameters.getName().concat(StoreConstants.FILE_EXTENSION),
storeParameters.getType(),
storeParameters.getRepositoryId(),
storeParameters.getNamespace()),
keyGenerator,
storeParameters.getType(),
TypedStoreContext.of(storeParameters)
);
}
}

View File

@@ -0,0 +1,93 @@
/*
* Copyright (c) 2020 - present Cloudogu GmbH
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3.
*
* 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 Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
package sonia.scm.store.file;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import sonia.scm.CopyOnWrite;
import sonia.scm.store.AbstractStore;
import sonia.scm.store.ConfigurationStore;
import sonia.scm.store.StoreException;
import sonia.scm.util.IOUtil;
import java.io.File;
import java.io.IOException;
import java.util.function.BooleanSupplier;
import static sonia.scm.CopyOnWrite.compute;
import static sonia.scm.CopyOnWrite.execute;
/**
* JAXB implementation of {@link ConfigurationStore}.
*
* @param <T>
*/
class JAXBConfigurationStore<T> extends AbstractStore<T> {
private static final Logger LOG = LoggerFactory.getLogger(JAXBConfigurationStore.class);
private final TypedStoreContext<T> context;
private final Class<T> type;
private final File configFile;
public JAXBConfigurationStore(TypedStoreContext<T> context, Class<T> type, File configFile, BooleanSupplier readOnly) {
super(readOnly);
this.context = context;
this.type = type;
this.configFile = configFile;
}
public Class<T> getType() {
return type;
}
@Override
protected T readObject() {
LOG.debug("load {} from store {}", type, configFile);
return compute(
() -> {
if (configFile.exists()) {
return context.unmarshal(configFile);
}
return null;
}
).withLockedFileForRead(configFile);
}
@Override
protected void writeObject(T object) {
LOG.debug("store {} to {}", object.getClass().getName(), configFile.getPath());
CopyOnWrite.withTemporaryFile(
temp -> context.marshal(object, temp.toFile()),
configFile.toPath()
);
}
@Override
protected void deleteObject() {
LOG.debug("deletes {}", configFile.getPath());
execute(() -> {
try {
IOUtil.delete(configFile);
} catch (IOException e) {
throw new StoreException("Failed to delete store object " + configFile.getPath(), e);
}
}).withLockedFileForWrite(configFile);
}
}

View File

@@ -0,0 +1,81 @@
/*
* Copyright (c) 2020 - present Cloudogu GmbH
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3.
*
* 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 Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
package sonia.scm.store.file;
import com.google.inject.Inject;
import com.google.inject.Singleton;
import sonia.scm.SCMContextProvider;
import sonia.scm.repository.RepositoryLocationResolver;
import sonia.scm.repository.RepositoryReadOnlyChecker;
import sonia.scm.store.ConfigurationStore;
import sonia.scm.store.ConfigurationStoreDecoratorFactory;
import sonia.scm.store.ConfigurationStoreFactory;
import sonia.scm.store.StoreDecoratorFactory;
import sonia.scm.store.TypedStoreParameters;
import java.util.Set;
/**
* JAXB implementation of {@link ConfigurationStoreFactory}.
*
*/
@Singleton
public class JAXBConfigurationStoreFactory extends FileBasedStoreFactory implements ConfigurationStoreFactory {
private final Set<ConfigurationStoreDecoratorFactory> decoratorFactories;
private final StoreCache<ConfigurationStore<?>> storeCache;
@Inject
public JAXBConfigurationStoreFactory(
SCMContextProvider contextProvider,
RepositoryLocationResolver repositoryLocationResolver,
RepositoryReadOnlyChecker readOnlyChecker,
Set<ConfigurationStoreDecoratorFactory> decoratorFactories,
StoreCacheFactory storeCacheFactory
) {
super(contextProvider, repositoryLocationResolver, Store.CONFIG, readOnlyChecker);
this.decoratorFactories = decoratorFactories;
this.storeCache = storeCacheFactory.createStoreCache(this::createStore);
}
@Override
@SuppressWarnings("unchecked")
public <T> ConfigurationStore<T> getStore(TypedStoreParameters<T> storeParameters) {
return (ConfigurationStore<T>) storeCache.getStore(storeParameters);
}
private <T> ConfigurationStore<T> createStore(TypedStoreParameters<T> storeParameters) {
TypedStoreContext<T> context = TypedStoreContext.of(storeParameters);
ConfigurationStore<T> store = new JAXBConfigurationStore<>(
context,
storeParameters.getType(),
getStoreLocation(storeParameters.getName().concat(StoreConstants.FILE_EXTENSION),
storeParameters.getType(),
storeParameters.getRepositoryId(),
storeParameters.getNamespace()),
() -> mustBeReadOnly(storeParameters)
);
for (ConfigurationStoreDecoratorFactory factory : decoratorFactories) {
store = factory.createDecorator(store, new StoreDecoratorFactory.Context(storeParameters));
}
return store;
}
}

View File

@@ -0,0 +1,124 @@
/*
* Copyright (c) 2020 - present Cloudogu GmbH
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3.
*
* 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 Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
package sonia.scm.store.file;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableMap.Builder;
import jakarta.xml.bind.JAXBException;
import jakarta.xml.bind.Marshaller;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import sonia.scm.CopyOnWrite;
import sonia.scm.security.KeyGenerator;
import sonia.scm.store.DataStore;
import sonia.scm.store.StoreException;
import sonia.scm.xml.XmlStreams;
import java.io.File;
import java.util.Map;
import java.util.Objects;
import static sonia.scm.CopyOnWrite.compute;
/**
* Jaxb implementation of {@link DataStore}.
*
* @param <T> type of stored data.
*/
class JAXBDataStore<T> extends FileBasedStore<T> implements DataStore<T> {
private static final Logger LOG = LoggerFactory.getLogger(JAXBDataStore.class);
private final KeyGenerator keyGenerator;
private final TypedStoreContext<T> context;
private final DataFileCache.DataFileCacheInstance cache;
JAXBDataStore(KeyGenerator keyGenerator, TypedStoreContext<T> context, File directory, boolean readOnly, DataFileCache.DataFileCacheInstance cache) {
super(directory, StoreConstants.FILE_EXTENSION, readOnly);
this.keyGenerator = keyGenerator;
this.cache = cache;
this.directory = directory;
this.context = context;
}
@Override
public void put(String id, T item) {
LOG.debug("put item {} to store", id);
assertNotReadOnly();
File file = getFile(id);
try {
Marshaller marshaller = context.createMarshaller();
marshaller.setProperty(Marshaller.JAXB_FORMATTED_OUTPUT, Boolean.TRUE);
CopyOnWrite.withTemporaryFile(
temp -> marshaller.marshal(item, XmlStreams.createWriter(temp.toFile())),
file.toPath(),
() -> cache.put(file, item)
);
} catch (JAXBException ex) {
throw new StoreException("could not write object with id ".concat(id),
ex);
}
}
@Override
public String put(T item) {
String key = keyGenerator.createKey();
put(key, item);
return key;
}
@Override
public Map<String, T> getAll() {
LOG.trace("get all items from data store");
Builder<String, T> builder = ImmutableMap.builder();
for (File file : Objects.requireNonNull(directory.listFiles())) {
if (file.isFile() && file.getName().endsWith(StoreConstants.FILE_EXTENSION)) {
builder.put(getId(file), read(file));
}
}
return builder.build();
}
@Override
protected void remove(File file) {
cache.remove(file);
super.remove(file);
}
@Override
protected T read(File file) {
return cache.get(file, () ->
compute(() -> {
if (file.exists()) {
LOG.trace("try to read {}", file);
return context.unmarshal(file);
}
return null;
}).withLockedFileForRead(file)
);
}
}

View File

@@ -0,0 +1,75 @@
/*
* Copyright (c) 2020 - present Cloudogu GmbH
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3.
*
* 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 Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
package sonia.scm.store.file;
import com.google.inject.Inject;
import com.google.inject.Singleton;
import sonia.scm.SCMContextProvider;
import sonia.scm.repository.RepositoryLocationResolver;
import sonia.scm.repository.RepositoryReadOnlyChecker;
import sonia.scm.security.KeyGenerator;
import sonia.scm.store.DataStore;
import sonia.scm.store.DataStoreFactory;
import sonia.scm.store.TypedStoreParameters;
import sonia.scm.util.IOUtil;
import java.io.File;
@Singleton
public class JAXBDataStoreFactory extends FileBasedStoreFactory
implements DataStoreFactory {
private final KeyGenerator keyGenerator;
private final StoreCache<DataStore<?>> storeCache;
private final DataFileCache dataFileCache;
@Inject
public JAXBDataStoreFactory(
SCMContextProvider contextProvider,
RepositoryLocationResolver repositoryLocationResolver,
KeyGenerator keyGenerator,
RepositoryReadOnlyChecker readOnlyChecker,
DataFileCache dataFileCache,
StoreCacheFactory storeCacheFactory
) {
super(contextProvider, repositoryLocationResolver, Store.DATA, readOnlyChecker);
this.keyGenerator = keyGenerator;
this.dataFileCache = dataFileCache;
this.storeCache = storeCacheFactory.createStoreCache(this::createStore);
}
@Override
@SuppressWarnings("unchecked")
public <T> DataStore<T> getStore(TypedStoreParameters<T> storeParameters) {
return (DataStore<T>) storeCache.getStore(storeParameters);
}
private <T> DataStore<T> createStore(TypedStoreParameters<T> storeParameters) {
File storeLocation = getStoreLocation(storeParameters);
IOUtil.mkdirs(storeLocation);
return new JAXBDataStore<>(
keyGenerator,
TypedStoreContext.of(storeParameters),
storeLocation,
mustBeReadOnly(storeParameters),
dataFileCache.instanceFor(storeParameters.getType())
);
}
}

View File

@@ -0,0 +1,106 @@
/*
* Copyright (c) 2020 - present Cloudogu GmbH
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3.
*
* 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 Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
package sonia.scm.store.file;
import jakarta.inject.Inject;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import sonia.scm.SCMContextProvider;
import sonia.scm.repository.RepositoryLocationResolver;
import sonia.scm.update.PropertyFileAccess;
import sonia.scm.util.IOUtil;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.stream.Stream;
public class JAXBPropertyFileAccess implements PropertyFileAccess {
private static final Logger LOG = LoggerFactory.getLogger(JAXBPropertyFileAccess.class);
private final SCMContextProvider contextProvider;
private final RepositoryLocationResolver locationResolver;
@Inject
public JAXBPropertyFileAccess(SCMContextProvider contextProvider, RepositoryLocationResolver locationResolver) {
this.contextProvider = contextProvider;
this.locationResolver = locationResolver;
}
@Override
public Target renameGlobalConfigurationFrom(String oldName) {
return newName -> {
Path configDir = contextProvider.getBaseDirectory().toPath().resolve(StoreConstants.CONFIG_DIRECTORY_NAME);
Path oldConfigFile = configDir.resolve(oldName + StoreConstants.FILE_EXTENSION);
Path newConfigFile = configDir.resolve(newName + StoreConstants.FILE_EXTENSION);
Files.move(oldConfigFile, newConfigFile);
};
}
@Override
public StoreFileTools forStoreName(String storeName) {
return new StoreFileTools() {
@Override
public void forStoreFiles(FileConsumer storeFileConsumer) throws IOException {
Path v1storeDir = computeV1StoreDir();
if (Files.exists(v1storeDir) && Files.isDirectory(v1storeDir)) {
try (Stream<Path> fileStream = Files.list(v1storeDir)) {
fileStream.filter(p -> p.toString().endsWith(StoreConstants.FILE_EXTENSION)).forEach(p -> {
try {
String storeName = extractStoreName(p);
storeFileConsumer.accept(p, storeName);
} catch (IOException e) {
throw new RuntimeException("could not call consumer for store file " + p + " with name " + storeName, e);
}
});
}
}
}
@Override
public void moveAsRepositoryStore(Path storeFile, String repositoryId) throws IOException {
Path repositoryLocation;
try {
repositoryLocation = locationResolver
.forClass(Path.class)
.getLocation(repositoryId);
} catch (IllegalStateException e) {
LOG.info("ignoring store file {} because there is no repository location for repository id {}", storeFile, repositoryId);
return;
}
Path target = repositoryLocation
.resolve(Store.DATA.getRepositoryStoreDirectory())
.resolve(storeName);
IOUtil.mkdirs(target.toFile());
Path resolvedSourceFile = computeV1StoreDir().resolve(storeFile);
Path resolvedTargetFile = target.resolve(storeFile.getFileName());
LOG.trace("moving file {} to {}", resolvedSourceFile, resolvedTargetFile);
Files.move(resolvedSourceFile, resolvedTargetFile);
}
private Path computeV1StoreDir() {
return contextProvider.getBaseDirectory().toPath().resolve("var").resolve("data").resolve(storeName);
}
private String extractStoreName(Path p) {
String fileName = p.getFileName().toString();
return fileName.substring(0, fileName.length() - StoreConstants.FILE_EXTENSION.length());
}
};
}
}

View File

@@ -0,0 +1,96 @@
/*
* Copyright (c) 2020 - present Cloudogu GmbH
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3.
*
* 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 Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
package sonia.scm.store.file;
import sonia.scm.store.StoreType;
import java.io.File;
enum Store {
CONFIG("config"),
DATA("data"),
BLOB("blob");
private static final String GLOBAL_STORE_BASE_DIRECTORY = "var";
static final String STORE_DIRECTORY = "store";
static Store forStoreType(StoreType storeType) {
switch (storeType) {
case BLOB:
return BLOB;
case DATA:
return DATA;
case CONFIG:
case CONFIG_ENTRY:
return CONFIG;
default:
throw new IllegalArgumentException("unknown store type: " + storeType);
}
}
private final String directory;
Store(String directory) {
this.directory = directory;
}
/**
* Get the relative store directory path to be stored in the repository root
* <p>
* The repository store directories are:
* repo_base_dir/store/config/
* repo_base_dir/store/blob/
* repo_base_dir/store/data/
*
* @return the relative store directory path to be stored in the repository root
*/
public String getRepositoryStoreDirectory() {
return STORE_DIRECTORY + File.separator + directory;
}
/**
* Get the relative store directory path to be stored in the namespace root
* <p>
* The namespace store directories are:
* namespace_dir/store/config/
* namespace_dir/store/blob/
* namespace_dir/store/data/
*
* @return the relative store directory path to be stored in the namespace root
*/
public String getNamespaceStoreDirectory() {
return STORE_DIRECTORY + File.separator + directory;
}
/**
* Get the relative store directory path to be stored in the global root
* <p>
* The global store directories are:
* base_dir/config/
* base_dir/var/blob/
* base_dir/var/data/
*
* @return the relative store directory path to be stored in the global root
*/
public String getGlobalStoreDirectory() {
if (this.equals(CONFIG)) {
return directory;
}
return GLOBAL_STORE_BASE_DIRECTORY + File.separator + directory;
}
}

View File

@@ -0,0 +1,57 @@
/*
* Copyright (c) 2020 - present Cloudogu GmbH
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3.
*
* 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 Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
package sonia.scm.store.file;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import sonia.scm.store.TypedStoreParameters;
import java.util.HashMap;
import java.util.Map;
import java.util.function.Function;
import static java.util.Collections.synchronizedMap;
class StoreCache <S> {
private static final Logger LOG = LoggerFactory.getLogger(StoreCache.class);
private final Function<TypedStoreParameters<?>, S> cachingStoreCreator;
private final Map<TypedStoreParameters<?>, S> storeCache;
StoreCache(Function<TypedStoreParameters<?>, S> storeCreator, Boolean storeCacheEnabled) {
if (storeCacheEnabled) {
LOG.info("store cache enabled");
storeCache = synchronizedMap(new HashMap<>());
cachingStoreCreator = storeParameters -> storeCache.computeIfAbsent(storeParameters, storeCreator);
} else {
cachingStoreCreator = storeCreator;
storeCache = null;
}
}
S getStore(TypedStoreParameters<?> storeParameters) {
return cachingStoreCreator.apply(storeParameters);
}
void clearCache(String repositoryId) {
if (storeCache != null) {
storeCache.keySet().removeIf(parameters -> repositoryId.equals(parameters.getRepositoryId()));
}
}
}

View File

@@ -0,0 +1,38 @@
/*
* Copyright (c) 2020 - present Cloudogu GmbH
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3.
*
* 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 Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
package sonia.scm.store.file;
import jakarta.inject.Inject;
import sonia.scm.EagerSingleton;
import sonia.scm.config.ConfigValue;
@EagerSingleton
public class StoreCacheConfigProvider {
private final Boolean storeCacheEnabled;
@Inject
public StoreCacheConfigProvider(
@ConfigValue(key = "cache.store.enabled", defaultValue = "true", description = "Enabled caching for all persistence stores") Boolean storeCacheEnabled
) {
this.storeCacheEnabled = storeCacheEnabled;
}
public Boolean isStoreCacheEnabled() {
return storeCacheEnabled;
}
}

View File

@@ -0,0 +1,50 @@
/*
* Copyright (c) 2020 - present Cloudogu GmbH
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3.
*
* 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 Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
package sonia.scm.store.file;
import com.github.legman.Subscribe;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
import sonia.scm.repository.ClearRepositoryCacheEvent;
import sonia.scm.store.TypedStoreParameters;
import java.util.Collection;
import java.util.LinkedList;
import java.util.function.Function;
@Singleton
public class StoreCacheFactory {
private final StoreCacheConfigProvider storeCacheConfigProvider;
private final Collection<StoreCache<?>> caches = new LinkedList<>();
@Inject
public StoreCacheFactory(StoreCacheConfigProvider storeCacheConfigProvider) {
this.storeCacheConfigProvider = storeCacheConfigProvider;
}
<S> StoreCache<S> createStoreCache(Function<TypedStoreParameters<?>, S> storeCreator) {
StoreCache<S> cache = new StoreCache<>(storeCreator, storeCacheConfigProvider.isStoreCacheEnabled());
caches.add(cache);
return cache;
}
@Subscribe(async = false)
public void clearCache(ClearRepositoryCacheEvent event) {
caches.forEach(storeCache -> storeCache.clearCache(event.getRepository().getId()));
}
}

View File

@@ -0,0 +1,51 @@
/*
* Copyright (c) 2020 - present Cloudogu GmbH
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3.
*
* 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 Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
package sonia.scm.store.file;
/**
* Store constants for xml implementations.
*
*/
public class StoreConstants
{
private StoreConstants() { }
public static final String CONFIG_DIRECTORY_NAME = "config";
/**
* Name of the parent of data or blob directories.
* @since 2.23.0
*/
public static final String VARIABLE_DATA_DIRECTORY_NAME = "var";
/**
* Name of data directories.
* @since 2.23.0
*/
public static final String DATA_DIRECTORY_NAME = "data";
/**
* Name of blob directories.
* @since 2.23.0
*/
public static final String BLOG_DIRECTORY_NAME = "data";
public static final String REPOSITORY_METADATA = "metadata";
public static final String FILE_EXTENSION = ".xml";
}

View File

@@ -0,0 +1,142 @@
/*
* Copyright (c) 2020 - present Cloudogu GmbH
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3.
*
* 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 Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
package sonia.scm.store.file;
import jakarta.xml.bind.JAXBContext;
import jakarta.xml.bind.JAXBException;
import jakarta.xml.bind.Marshaller;
import jakarta.xml.bind.Unmarshaller;
import jakarta.xml.bind.annotation.adapters.XmlAdapter;
import lombok.extern.slf4j.Slf4j;
import sonia.scm.store.StoreException;
import sonia.scm.store.TypedStoreParameters;
import sonia.scm.xml.XmlStreams;
import java.io.File;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicReference;
@Slf4j
final class TypedStoreContext<T> {
private final JAXBContext jaxbContext;
private final TypedStoreParameters<T> parameters;
private static final Map<Class<?>, JAXBContext> contextCache = new HashMap<>();
private TypedStoreContext(JAXBContext jaxbContext, TypedStoreParameters<T> parameters) {
this.jaxbContext = jaxbContext;
this.parameters = parameters;
}
static <T> TypedStoreContext<T> of(TypedStoreParameters<T> parameters) {
JAXBContext jaxbContext;
synchronized (contextCache) {
jaxbContext = contextCache.computeIfAbsent(parameters.getType(), type -> createJaxbContext(parameters));
}
return new TypedStoreContext<>(jaxbContext, parameters);
}
private static <T> JAXBContext createJaxbContext(TypedStoreParameters<T> parameters) {
try {
return JAXBContext.newInstance(parameters.getType());
} catch (JAXBException e) {
throw new StoreException("failed to create context for store", e);
}
}
T unmarshal(File file) {
log.trace("unmarshal file {}", file);
AtomicReference<T> ref = new AtomicReference<>();
withUnmarshaller(unmarshaller -> {
T value = parameters.getType().cast(unmarshaller.unmarshal(file));
ref.set(value);
});
return ref.get();
}
void marshal(Object object, File file) {
log.trace("marshal file {}", file);
withMarshaller(marshaller -> marshaller.marshal(object, XmlStreams.createWriter(file)));
}
void withMarshaller(ThrowingConsumer<Marshaller> consumer) {
Marshaller marshaller = createMarshaller();
withClassLoader(consumer, marshaller);
}
void withUnmarshaller(ThrowingConsumer<Unmarshaller> consumer) {
Unmarshaller unmarshaller = createUnmarshaller();
withClassLoader(consumer, unmarshaller);
}
Class<?> getType() {
return parameters.getType();
}
private <C> void withClassLoader(ThrowingConsumer<C> consumer, C consume) {
ClassLoader contextClassLoader = null;
Optional<ClassLoader> classLoader = parameters.getClassLoader();
if (classLoader.isPresent()) {
contextClassLoader = Thread.currentThread().getContextClassLoader();
Thread.currentThread().setContextClassLoader(classLoader.get());
}
try {
consumer.consume(consume);
} catch (Exception e) {
throw new StoreException("failure during marshalling/unmarshalling", e);
} finally {
if (contextClassLoader != null) {
Thread.currentThread().setContextClassLoader(contextClassLoader);
}
}
}
Marshaller createMarshaller() {
try {
Marshaller marshaller = jaxbContext.createMarshaller();
marshaller.setProperty(Marshaller.JAXB_FORMATTED_OUTPUT, Boolean.TRUE);
for (XmlAdapter<?, ?> adapter : parameters.getAdapters()) {
marshaller.setAdapter(adapter);
}
return marshaller;
} catch (JAXBException e) {
throw new StoreException("could not create marshaller", e);
}
}
private Unmarshaller createUnmarshaller() {
try {
Unmarshaller unmarshaller = jaxbContext.createUnmarshaller();
for (XmlAdapter<?, ?> adapter : parameters.getAdapters()) {
unmarshaller.setAdapter(adapter);
}
return unmarshaller;
} catch (JAXBException e) {
throw new StoreException("could not create unmarshaller", e);
}
}
@FunctionalInterface
interface ThrowingConsumer<T> {
@SuppressWarnings("java:S112") // we need to throw Exception here
void consume(T item) throws Exception;
}
}

View File

@@ -0,0 +1,30 @@
/*
* Copyright (c) 2020 - present Cloudogu GmbH
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3.
*
* 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 Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
package sonia.scm.store.sqlite;
import sonia.scm.store.StoreException;
/**
* This exception is thrown if a name for a store element doesn't meet the internal verification requirements.
*
* @since 3.7.0
*/
class BadStoreNameException extends StoreException {
BadStoreNameException(String badName) {
super("This name has been rejected: " + badName);
}
}

View File

@@ -0,0 +1,49 @@
/*
* Copyright (c) 2020 - present Cloudogu GmbH
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3.
*
* 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 Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
package sonia.scm.store.sqlite;
import java.sql.PreparedStatement;
import java.sql.SQLException;
import java.util.List;
abstract class ConditionalSQLStatement implements SQLNodeWithValue {
private final List<SQLNodeWithValue> whereCondition;
ConditionalSQLStatement(List<SQLNodeWithValue> whereCondition) {
this.whereCondition = whereCondition;
}
void appendWhereClause(StringBuilder query) {
if (!whereCondition.isEmpty()) {
query.append(" WHERE ").append(new SQLLogicalCondition("AND", whereCondition).toSQL());
}
}
@Override
public int apply(PreparedStatement statement, int index) throws SQLException {
for (SQLNodeWithValue condition : whereCondition) {
index = condition.apply(statement, index);
}
return index;
}
@Override
public String toString() {
return "SQL statement: " + toSQL();
}
}

View File

@@ -0,0 +1,122 @@
/*
* Copyright (c) 2020 - present Cloudogu GmbH
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3.
*
* 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 Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
package sonia.scm.store.sqlite;
import lombok.extern.slf4j.Slf4j;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReadWriteLock;
@Slf4j
class LoggingReadWriteLock implements ReadWriteLock {
private static int rwLockCounter = 0;
private static int lockCounter = 0;
private final ReadWriteLock delegate;
private final int nr;
LoggingReadWriteLock(ReadWriteLock delegate) {
this.delegate = delegate;
synchronized (LoggingReadWriteLock.class) {
nr = ++rwLockCounter;
}
}
@Override
public Lock readLock() {
return new LoggingLock(nr, delegate.readLock(), "read");
}
@Override
public Lock writeLock() {
return new LoggingLock(nr, delegate.writeLock(), "write");
}
private static class LoggingLock implements Lock {
private final int nr;
private final int subNr;
private final Lock delegate;
private final String purpose;
private long lockStart;
private LoggingLock(int nr, Lock delegate, String purpose) {
this.nr = nr;
this.delegate = delegate;
this.purpose = purpose;
synchronized (LoggingReadWriteLock.class) {
subNr = ++lockCounter;
}
}
@Override
public void lock() {
log.trace("request {} lock for lock nr {}-{}", purpose, nr, subNr);
delegate.lock();
lockStart = System.nanoTime();
log.trace("got {} lock for lock nr {}-{}", purpose, nr, subNr);
}
@Override
public void lockInterruptibly() throws InterruptedException {
log.trace("try interruptibly {} lock for lock nr {}-{}", purpose, nr, subNr);
delegate.lockInterruptibly();
lockStart = System.nanoTime();
log.trace("got {} lock for lock nr {}-{}", purpose, nr, subNr);
}
@Override
public boolean tryLock() {
log.trace("try {} lock for lock nr {}-{}", purpose, nr, subNr);
boolean result = delegate.tryLock();
if (result) {
lockStart = System.nanoTime();
}
log.trace("result for {} lock for lock nr {}-{}: {}", purpose, nr, subNr, result);
return result;
}
@Override
public boolean tryLock(long l, TimeUnit timeUnit) throws InterruptedException {
log.trace("try {} lock for lock nr {}-{}", purpose, nr, subNr);
boolean result = delegate.tryLock(l, timeUnit);
if (result) {
lockStart = System.nanoTime();
}
log.trace("result for {} lock for lock nr {}-{}: {}", purpose, nr, subNr, result);
return result;
}
@Override
public void unlock() {
log.trace("release {} lock for lock nr {}-{}", purpose, nr, subNr);
delegate.unlock();
long duration = System.nanoTime() - lockStart;
log.trace("{} lock released after {}ns for lock nr {}-{}", purpose, duration, nr, subNr);
lockStart = 0;
}
@Override
public Condition newCondition() {
return delegate.newCondition();
}
}
}

View File

@@ -0,0 +1,159 @@
/*
* Copyright (c) 2020 - present Cloudogu GmbH
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3.
*
* 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 Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
package sonia.scm.store.sqlite;
import lombok.Getter;
import lombok.Setter;
import sonia.scm.store.LeafCondition;
import sonia.scm.store.Operator;
import sonia.scm.store.QueryableStore;
import java.sql.PreparedStatement;
import java.sql.SQLException;
import java.time.Instant;
import static sonia.scm.store.sqlite.SQLiteIdentifiers.computeColumnIdentifier;
/**
* <b>SQLCondition</b> represents a condition given in an agnostic SQL statement.
*
* @since 3.7.0
*/
@Getter
@Setter
class SQLCondition implements SQLNodeWithValue {
private String operatorPrefix;
private String operatorPostfix;
private SQLField field;
private SQLValue value;
public SQLCondition(String operator, SQLField field, SQLValue value) {
this(operator, "", field, value);
}
public SQLCondition(String operatorPrefix, String operatorPostfix, SQLField field, SQLValue value) {
this.operatorPrefix = operatorPrefix;
this.operatorPostfix = operatorPostfix;
this.field = field;
this.value = value;
}
public static SQLCondition createConditionWithOperatorAndValue(LeafCondition<?, ?> leafCondition) {
QueryableStore.QueryField<?, ?> queryField = leafCondition.getField();
String operatorPrefix = mapOperatorPrefix(leafCondition.getOperator());
String operatorPostfix = mapOperatorPostfix(leafCondition.getOperator());
SQLField field = new SQLField(computeSQLField(queryField));
SQLValue value = determineValueBasedOnOperator(leafCondition);
if (queryField instanceof QueryableStore.CollectionQueryField<?>) {
return new ExistsSQLCondition(operatorPrefix, field, value);
} else if (queryField instanceof QueryableStore.MapQueryField<?>) {
return new ExistsSQLCondition(operatorPrefix, field, value);
} else {
return new SQLCondition(operatorPrefix, operatorPostfix, field, value);
}
}
private static String mapOperatorPrefix(Operator operator) {
return switch (operator) {
case EQ -> "=";
case LESS -> "<";
case LESS_OR_EQUAL -> "<=";
case GREATER -> ">";
case GREATER_OR_EQUAL -> ">=";
case CONTAINS -> "LIKE '%' ||";
case NULL -> "IS NULL";
case IN -> "IN";
case KEY -> "key =";
case VALUE -> "value =";
};
}
private static String mapOperatorPostfix(Operator operator) {
return switch (operator) {
case CONTAINS -> "|| '%'";
default -> "";
};
}
private static String computeSQLField(QueryableStore.QueryField<?, ?> queryField) {
if (queryField instanceof QueryableStore.CollectionQueryField<?>) {
return "select * from json_each(payload, '$." + queryField.getName() + "') where value ";
} else if (queryField instanceof QueryableStore.MapQueryField<?>) {
return "select * from json_each(payload, '$." + queryField.getName() + "') where ";
} else if (queryField instanceof QueryableStore.InstantQueryField) {
return "json_extract(payload, '$." + queryField.getName() + "')";
} else if (queryField instanceof QueryableStore.CollectionSizeQueryField<?>) {
return "json_array_length(payload, '$." + queryField.getName() + "')";
} else if (queryField instanceof QueryableStore.MapSizeQueryField<?>) {
return "(select count(*) from json_each(payload, '$." + queryField.getName() + "')) ";
} else if (queryField.isIdField()) {
return computeColumnIdentifier(queryField.getName());
} else {
return "json_extract(payload, '$." + queryField.getName() + "')";
}
}
private static SQLValue determineValueBasedOnOperator(LeafCondition<?, ?> leafCondition) {
Operator operator = leafCondition.getOperator();
Object value = leafCondition.getValue();
switch (operator) {
case NULL:
return new SQLValue(null);
case IN:
if (value instanceof Object[] valueArray) {
return new SQLValue(valueArray);
} else {
throw new IllegalArgumentException("Value for IN operator must be an array.");
}
default:
return new SQLValue(computeParameter(leafCondition));
}
}
private static Object computeParameter(LeafCondition<?, ?> leafCondition) {
if (leafCondition.getField() instanceof QueryableStore.InstantQueryField) {
return ((Instant) leafCondition.getValue()).toEpochMilli();
} else {
return leafCondition.getValue();
}
}
@Override
public String toSQL() {
String fieldSQL = (field != null) ? field.toSQL() : "";
return fieldSQL + " " + operatorPrefix + " " + value.toSQL() + " " + operatorPostfix + " ";
}
@Override
public int apply(PreparedStatement statement, int index) throws SQLException {
return value.apply(statement, index);
}
private static class ExistsSQLCondition extends SQLCondition {
public ExistsSQLCondition(String operator, SQLField field, SQLValue value) {
super(operator, field, value);
}
@Override
public String toSQL() {
return "exists(" + super.toSQL() + ")";
}
}
}

View File

@@ -0,0 +1,60 @@
/*
* Copyright (c) 2020 - present Cloudogu GmbH
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3.
*
* 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 Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
package sonia.scm.store.sqlite;
import sonia.scm.store.Condition;
import sonia.scm.store.LeafCondition;
import sonia.scm.store.LogicalCondition;
import java.util.ArrayList;
import java.util.List;
class SQLConditionMapper {
/**
* Maps a LogicalCondition to an SQLLogicalCondition and appends its value to the provided parameters list.
*
* @param logicalCondition The condition to map.
* @return A new SQLCondition object representing the mapped condition.
*/
static SQLLogicalCondition mapToSQLLogicalCondition(LogicalCondition<?> logicalCondition) {
List<SQLNodeWithValue> sqlConditions = new ArrayList<>();
for (Condition<?> condition : logicalCondition.getConditions()) {
if (condition instanceof LeafCondition) {
sqlConditions.add(SQLConditionMapper.mapToSQLCondition((LeafCondition<?, ?>) condition));
} else {
sqlConditions.add(SQLConditionMapper.mapToSQLLogicalCondition((LogicalCondition<?>) condition));
}
}
return new SQLLogicalCondition(
logicalCondition.getOperator().toString(),
sqlConditions
);
}
/**
* Maps a LeafCondition to an SQLCondition and appends its value to the provided parameters list.
*
* @param leafCondition The condition to map.
* @return A new SQLCondition object representing the mapped condition.
*/
static SQLCondition mapToSQLCondition(LeafCondition<?, ?> leafCondition) {
return SQLCondition.createConditionWithOperatorAndValue(leafCondition);
}
}

View File

@@ -0,0 +1,38 @@
/*
* Copyright (c) 2020 - present Cloudogu GmbH
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3.
*
* 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 Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
package sonia.scm.store.sqlite;
import java.util.List;
class SQLDeleteStatement extends ConditionalSQLStatement {
private final SQLTable fromTable;
SQLDeleteStatement(SQLTable fromTable, List<SQLNodeWithValue> whereCondition) {
super(whereCondition);
this.fromTable = fromTable;
}
@Override
public String toSQL() {
StringBuilder query = new StringBuilder();
query.append("DELETE FROM ").append(fromTable.toSQL());
appendWhereClause(query);
return query.toString();
}
}

View File

@@ -0,0 +1,41 @@
/*
* Copyright (c) 2020 - present Cloudogu GmbH
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3.
*
* 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 Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
package sonia.scm.store.sqlite;
import lombok.Getter;
/**
* Representation of a value of a row within an {@link SQLTable}.
*
* @since 3.7.0
*/
@Getter
class SQLField implements SQLNode {
static final SQLField PAYLOAD = new SQLField("json(payload)");
private final String name;
SQLField(String name) {
this.name = name;
}
@Override
public String toSQL() {
return name;
}
}

View File

@@ -0,0 +1,46 @@
/*
* Copyright (c) 2020 - present Cloudogu GmbH
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3.
*
* 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 Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
package sonia.scm.store.sqlite;
import java.sql.PreparedStatement;
import java.sql.SQLException;
class SQLInsertStatement implements SQLNodeWithValue {
private final SQLTable fromTable;
private final SQLValue values;
SQLInsertStatement(SQLTable fromTable, SQLValue values) {
this.fromTable = fromTable;
this.values = values;
}
@Override
public String toSQL() {
return "REPLACE INTO " + fromTable.toSQL() + " VALUES " + values.toSQL();
}
@Override
public int apply(PreparedStatement statement, int index) throws SQLException {
return values.apply(statement, index);
}
@Override
public String toString() {
return "SQL insert statement: " + toSQL();
}
}

View File

@@ -0,0 +1,69 @@
/*
* Copyright (c) 2020 - present Cloudogu GmbH
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3.
*
* 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 Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
package sonia.scm.store.sqlite;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.sql.PreparedStatement;
import java.sql.SQLException;
import java.util.List;
@Getter
@Setter
@NoArgsConstructor
class SQLLogicalCondition implements SQLNodeWithValue {
private String operator; // AND, OR
private List<SQLNodeWithValue> conditions;
SQLLogicalCondition(String operator, List<SQLNodeWithValue> conditions) {
this.operator = operator;
this.conditions = conditions;
}
@Override
public String toSQL() {
if (conditions == null || conditions.isEmpty()) {
return "";
}
StringBuilder sql = new StringBuilder();
if (operator.equals("NOT")) {
sql.append("NOT ");
}
for (int i = 0; i < conditions.size(); i++) {
if (i > 0) {
sql.append(" ").append(operator).append(" ");
}
String conditionSQL = conditions.get(i).toSQL();
sql.append("(").append(conditionSQL).append(")");
}
return sql.toString();
}
@Override
public int apply(PreparedStatement statement, int index) throws SQLException {
int currentIndex = index;
for (SQLNodeWithValue condition : conditions) {
currentIndex = condition.apply(statement, currentIndex);
}
return currentIndex;
}
}

View File

@@ -0,0 +1,21 @@
/*
* Copyright (c) 2020 - present Cloudogu GmbH
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3.
*
* 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 Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
package sonia.scm.store.sqlite;
interface SQLNode {
String toSQL();
}

View File

@@ -0,0 +1,24 @@
/*
* Copyright (c) 2020 - present Cloudogu GmbH
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3.
*
* 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 Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
package sonia.scm.store.sqlite;
import java.sql.PreparedStatement;
import java.sql.SQLException;
interface SQLNodeWithValue extends SQLNode {
int apply(PreparedStatement statement, int index) throws SQLException;
}

View File

@@ -0,0 +1,72 @@
/*
* Copyright (c) 2020 - present Cloudogu GmbH
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3.
*
* 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 Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
package sonia.scm.store.sqlite;
import java.util.List;
import java.util.stream.Collectors;
class SQLSelectStatement extends ConditionalSQLStatement {
private final List<SQLField> columns;
private final SQLTable fromTable;
private final String orderBy;
private final long limit;
private final long offset;
SQLSelectStatement(List<SQLField> columns, SQLTable fromTable, List<SQLNodeWithValue> whereCondition) {
this(columns, fromTable, whereCondition, null, 0, 0);
}
SQLSelectStatement(List<SQLField> columns, SQLTable fromTable, List<SQLNodeWithValue> whereCondition, String orderBy, long limit, long offset) {
super(whereCondition);
this.columns = columns;
this.fromTable = fromTable;
this.orderBy = orderBy;
this.limit = limit;
this.offset = offset;
}
@Override
public String toSQL() {
StringBuilder query = new StringBuilder();
query.append("SELECT ");
if (columns != null && !columns.isEmpty()) {
String columnList = columns.stream()
.map(SQLField::toSQL)
.collect(Collectors.joining(", "));
query.append(columnList);
}
query.append(" FROM ").append(fromTable.toSQL());
appendWhereClause(query);
if (orderBy != null && !orderBy.isEmpty()) {
query.append(" ORDER BY ").append(orderBy);
}
if (limit > 0) {
query.append(" LIMIT ").append(limit);
}
if (offset > 0) {
query.append(" OFFSET ").append(offset);
}
return query.toString();
}
}

View File

@@ -0,0 +1,30 @@
/*
* Copyright (c) 2020 - present Cloudogu GmbH
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3.
*
* 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 Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
package sonia.scm.store.sqlite;
class SQLTable implements SQLNode {
private final String name;
SQLTable(String name) {
this.name = name;
}
@Override
public String toSQL() {
return name;
}
}

View File

@@ -0,0 +1,96 @@
/*
* Copyright (c) 2020 - present Cloudogu GmbH
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3.
*
* 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 Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
package sonia.scm.store.sqlite;
import lombok.extern.slf4j.Slf4j;
import java.sql.PreparedStatement;
import java.sql.SQLException;
import java.util.List;
/**
* Representation of a column or a list of columns within an {@link SQLTable}.
*
* @since 3.7.0
*/
@Slf4j
class SQLValue implements SQLNodeWithValue {
private final Object value;
SQLValue(Object value) {
this.value = value;
}
@Override
public String toSQL() {
if (value == null) {
return "";
}
if (value instanceof Object[] valueArray) {
return generatePlaceholders(valueArray);
} else if (value instanceof List<?> valueList) {
return generatePlaceholders(valueList);
}
return "?";
}
@Override
public int apply(PreparedStatement statement, int index) throws SQLException {
if (value instanceof Object[] valueArray) {
for (int i = 0; i < valueArray.length; i++) {
set(index + i, valueArray[i], statement);
}
return index + valueArray.length;
} else if (value instanceof List<?> valueList) {
for (int i = 0; i < valueList.size(); i++) {
set(index + i, valueList.get(i), statement);
}
return index + valueList.size();
} else if (value == null) {
return index;
} else {
set(index, value, statement);
return index + 1;
}
}
private static void set(int index, Object value, PreparedStatement statement) throws SQLException {
log.trace("set index {} to value '{}'", index, value);
statement.setObject(index, value);
}
private String generatePlaceholders(Object[] valueArray) {
return generatePlaceholders(valueArray.length);
}
private String generatePlaceholders(List<?> valueList) {
return generatePlaceholders(valueList.size());
}
private String generatePlaceholders(int length) {
StringBuilder placeholdersBuilder = new StringBuilder();
for (int i = 0; i < length; i++) {
if (i > 0) {
placeholdersBuilder.append(", ");
}
placeholdersBuilder.append("?");
}
return "(" + placeholdersBuilder + ")";
}
}

View File

@@ -0,0 +1,67 @@
/*
* Copyright (c) 2020 - present Cloudogu GmbH
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3.
*
* 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 Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
package sonia.scm.store.sqlite;
import com.google.common.base.Strings;
import sonia.scm.plugin.QueryableTypeDescriptor;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
class SQLiteIdentifiers {
private static final Pattern PATTERN = Pattern.compile("^\\w+$");
private static final String STORE_TABLE_SUFFIX = "_STORE";
static String computeTableName(QueryableTypeDescriptor queryableTypeDescriptor) {
if (Strings.isNullOrEmpty(queryableTypeDescriptor.getName())) {
String className = queryableTypeDescriptor.getClazz();
return sanitize(computeSqlIdentifier(removeClassSuffix(className)) + STORE_TABLE_SUFFIX);
} else {
return sanitize(queryableTypeDescriptor.getName() + STORE_TABLE_SUFFIX);
}
}
static String computeColumnIdentifier(String className) {
if (className == null) {
return "ID";
}
String nameWithoutClassSuffix = removeClassSuffix(className);
String classNameWithoutPackage = nameWithoutClassSuffix.substring(nameWithoutClassSuffix.lastIndexOf('.') + 1);
return computeSqlIdentifier(classNameWithoutPackage) + "_ID";
}
private static String computeSqlIdentifier(String className) {
return sanitize(className.replace("_", "__").replace('.', '_'));
}
private static String removeClassSuffix(String className) {
if (className.endsWith(".class")) {
return className.substring(0, className.length() - 6);
}
return className;
}
static String sanitize(String name) throws BadStoreNameException {
Matcher matcher = PATTERN.matcher(name);
if (!matcher.matches()) {
throw new BadStoreNameException(name);
} else {
return name;
}
}
}

View File

@@ -0,0 +1,169 @@
/*
* Copyright (c) 2020 - present Cloudogu GmbH
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3.
*
* 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 Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
package sonia.scm.store.sqlite;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import sonia.scm.plugin.QueryableTypeDescriptor;
import sonia.scm.security.KeyGenerator;
import sonia.scm.store.QueryableMutableStore;
import sonia.scm.store.StoreException;
import java.sql.Connection;
import java.sql.ResultSet;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.locks.ReadWriteLock;
@Slf4j
class SQLiteQueryableMutableStore<T> extends SQLiteQueryableStore<T> implements QueryableMutableStore<T> {
private final ObjectMapper objectMapper;
private final KeyGenerator keyGenerator;
private final Class<T> clazz;
private final String[] parentIds;
public SQLiteQueryableMutableStore(ObjectMapper objectMapper,
KeyGenerator keyGenerator,
Connection connection,
Class<T> clazz,
QueryableTypeDescriptor queryableTypeDescriptor,
String[] parentIds,
ReadWriteLock lock) {
super(objectMapper, connection, clazz, queryableTypeDescriptor, parentIds, lock);
this.objectMapper = objectMapper;
this.keyGenerator = keyGenerator;
this.clazz = clazz;
this.parentIds = parentIds;
}
@Override
public String put(T item) {
String id = keyGenerator.createKey();
put(id, item);
return id;
}
@Override
public void put(String id, T item) {
List<String> columnsToInsert = new ArrayList<>(Arrays.asList(parentIds));
columnsToInsert.add(id);
columnsToInsert.add(marshal(item));
SQLInsertStatement sqlInsertStatement =
new SQLInsertStatement(
computeFromTable(),
new SQLValue(columnsToInsert)
);
executeWrite(
sqlInsertStatement,
statement -> {
statement.executeUpdate();
return null;
}
);
}
@Override
public Map<String, T> getAll() {
List<SQLField> columns = List.of(
SQLField.PAYLOAD,
new SQLField("ID")
);
SQLSelectStatement sqlStatementQuery =
new SQLSelectStatement(
columns,
computeFromTable(),
computeConditionsForAllValues()
);
return executeRead(
sqlStatementQuery,
statement -> {
HashMap<String, T> result = new HashMap<>();
ResultSet resultSet = statement.executeQuery();
while (resultSet.next()) {
result.put(resultSet.getString(2), objectMapper.readValue(resultSet.getString(1), clazz));
}
return Collections.unmodifiableMap(result);
}
);
}
@Override
public void remove(String id) {
SQLDeleteStatement sqlStatementQuery =
new SQLDeleteStatement(
computeFromTable(),
computeConditionsFor(id)
);
executeWrite(
sqlStatementQuery,
statement -> {
statement.executeUpdate();
return null;
}
);
}
@Override
public T get(String id) {
SQLSelectStatement sqlStatementQuery =
new SQLSelectStatement(
List.of(SQLField.PAYLOAD),
computeFromTable(),
computeConditionsFor(id)
);
return executeRead(
sqlStatementQuery,
statement -> {
ResultSet resultSet = statement.executeQuery();
if (!resultSet.next()) {
return null;
}
String json = resultSet.getString(1);
if (json == null) {
return null;
}
return objectMapper.readValue(json, clazz);
}
);
}
private String marshal(T item) {
try {
return objectMapper.writeValueAsString(item);
} catch (JsonProcessingException e) {
throw new StoreException("Failed to marshal item as json", e);
}
}
private List<SQLNodeWithValue> computeConditionsFor(String id) {
List<SQLNodeWithValue> conditions = computeConditionsForAllValues();
conditions.add(new SQLCondition("=", new SQLField("ID"), new SQLValue(id)));
return conditions;
}
}

View File

@@ -0,0 +1,705 @@
/*
* Copyright (c) 2020 - present Cloudogu GmbH
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3.
*
* 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 Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
package sonia.scm.store.sqlite;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import sonia.scm.plugin.QueryableTypeDescriptor;
import sonia.scm.store.Condition;
import sonia.scm.store.Conditions;
import sonia.scm.store.LeafCondition;
import sonia.scm.store.LogicalCondition;
import sonia.scm.store.QueryableMaintenanceStore;
import sonia.scm.store.QueryableStore;
import sonia.scm.store.StoreException;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.NoSuchElementException;
import java.util.Optional;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.function.BooleanSupplier;
import java.util.stream.Stream;
import static sonia.scm.store.sqlite.SQLiteIdentifiers.computeColumnIdentifier;
import static sonia.scm.store.sqlite.SQLiteIdentifiers.computeTableName;
@Slf4j
class SQLiteQueryableStore<T> implements QueryableStore<T>, QueryableMaintenanceStore<T> {
public static final String TEMPORARY_UPDATE_TABLE_NAME = "update_tmp";
private final ObjectMapper objectMapper;
private final Connection connection;
private final Class<T> clazz;
private final QueryableTypeDescriptor queryableTypeDescriptor;
private final String[] parentIds;
private final ReadWriteLock lock;
public SQLiteQueryableStore(ObjectMapper objectMapper,
Connection connection,
Class<T> clazz,
QueryableTypeDescriptor queryableTypeDescriptor,
String[] parentIds,
ReadWriteLock lock) {
this.objectMapper = objectMapper;
this.connection = connection;
this.clazz = clazz;
this.parentIds = parentIds;
this.queryableTypeDescriptor = queryableTypeDescriptor;
this.lock = lock;
}
@Override
public Query<T, T> query(Condition<T>... conditions) {
return new SQLiteQuery<>(clazz, conditions);
}
@Override
public void clear() {
List<SQLNodeWithValue> parentConditions = new ArrayList<>();
evaluateParentConditions(parentConditions);
SQLDeleteStatement sqlStatementQuery =
new SQLDeleteStatement(
computeFromTable(),
parentConditions
);
executeWrite(
sqlStatementQuery,
statement -> {
statement.executeUpdate();
return null;
}
);
}
@Override
public Collection<RawRow> readRaw() {
return readAllAs(RawRow::new);
}
@Override
public Collection<Row<T>> readAll() {
return readAllAs(clazz);
}
@Override
public <U> Collection<Row<U>> readAllAs(Class<U> type) {
return readAllAs((parentIds, id, json) -> new Row<>(parentIds, id, objectMapper.readValue(json, type)));
}
private <R> Collection<R> readAllAs(RowBuilder<R> rowBuilder) {
List<SQLNodeWithValue> parentConditions = new ArrayList<>();
evaluateParentConditions(parentConditions);
List<SQLField> fields = new ArrayList<>();
addParentIdSQLFields(fields);
int parentIdsLength = fields.size() - 1; // addParentIdSQLFields has already added the ID field
fields.add(new SQLField("PAYLOAD"));
SQLSelectStatement sqlSelectQuery =
new SQLSelectStatement(
fields,
computeFromTable(),
parentConditions
);
return executeRead(
sqlSelectQuery,
statement -> {
List<R> result = new ArrayList<>();
ResultSet resultSet = statement.executeQuery();
String[] allParentIds = new String[parentIdsLength];
while (resultSet.next()) {
for (int i = 0; i < parentIdsLength; i++) {
allParentIds[i] = resultSet.getString(i + 1);
}
String id = resultSet.getString(parentIdsLength + 1);
String json = resultSet.getString(parentIdsLength + 2);
result.add(rowBuilder.build(allParentIds, id, json));
}
return Collections.unmodifiableList(result);
}
);
}
@Override
@SuppressWarnings("rawtypes")
public void writeAll(Stream<Row> rows) {
writeRaw(rows.map(row -> new RawRow(row.getParentIds(), row.getId(), serialize(row.getValue()))));
}
@Override
public void writeRaw(Stream<RawRow> rows) {
transactional(
() -> {
rows.forEach(row -> {
List<String> columnsToInsert = new ArrayList<>(Arrays.asList(row.getParentIds()));
// overwrite parentIds from the export with the parentIds of the current store:
for (int i = 0; i < parentIds.length; i++) {
columnsToInsert.set(i, parentIds[i]);
}
columnsToInsert.add(row.getId());
columnsToInsert.add(row.getValue());
SQLInsertStatement sqlInsertStatement =
new SQLInsertStatement(
computeFromTable(),
new SQLValue(columnsToInsert)
);
executeWrite(
sqlInsertStatement,
statement -> {
statement.executeUpdate();
return null;
}
);
});
return true;
}
);
}
@Override
public MaintenanceIterator<T> iterateAll() {
List<SQLField> columns = new LinkedList<>();
columns.add(new SQLField("payload"));
addParentIdSQLFields(columns);
return new TemporaryTableMaintenanceIterator(columns);
}
public void transactional(BooleanSupplier callback) {
log.debug("start transactional operation");
Lock writeLock = lock.writeLock();
writeLock.lock();
try {
getConnection().setAutoCommit(false);
boolean commit = callback.getAsBoolean();
if (commit) {
log.debug("commit operation");
getConnection().commit();
} else {
log.debug("rollback operation");
getConnection().rollback();
}
log.debug("operation finished");
} catch (SQLException e) {
throw new StoreException("failed to disable auto-commit", e);
} finally {
writeLock.unlock();
}
}
List<SQLNodeWithValue> computeConditionsForAllValues() {
List<SQLNodeWithValue> conditions = new ArrayList<>();
evaluateParentConditions(conditions);
return conditions;
}
SQLTable computeFromTable() {
return new SQLTable(computeTableName(queryableTypeDescriptor));
}
<R> R executeRead(SQLNodeWithValue sqlStatement, StatementCallback<R> callback) {
String sql = sqlStatement.toSQL();
log.debug("executing 'read' SQL: {}", sql);
return executeWithLock(sqlStatement, callback, lock.readLock(), sql);
}
<R> R executeWrite(SQLNodeWithValue sqlStatement, StatementCallback<R> callback) {
String sql = sqlStatement.toSQL();
log.debug("executing 'write' SQL: {}", sql);
return executeWithLock(sqlStatement, callback, lock.writeLock(), sql);
}
private <R> R executeWithLock(SQLNodeWithValue sqlStatement, StatementCallback<R> callback, Lock writeLock, String sql) {
writeLock.lock();
try (PreparedStatement statement = connection.prepareStatement(sql)) {
sqlStatement.apply(statement, 1);
return callback.apply(statement);
} catch (SQLException | JsonProcessingException e) {
throw new StoreException("An exception occurred while executing a query on SQLite database: " + sql, e);
} finally {
writeLock.unlock();
}
}
@Override
public void close() {
try {
log.debug("closing connection");
connection.close();
} catch (SQLException e) {
throw new StoreException("failed to close connection", e);
}
}
Connection getConnection() {
return connection;
}
private class SQLiteQuery<T_RESULT> implements Query<T, T_RESULT> {
private final Class<T_RESULT> resultType;
private final Class<T> entityType;
private final Condition<T> condition;
private final List<OrderBy<T>> orderBy;
private SQLiteQuery(Class<T_RESULT> resultType, Condition<T>[] conditions) {
this(resultType, resultType, conjunct(conditions), Collections.emptyList());
}
@SuppressWarnings({"rawtypes", "unchecked"})
private SQLiteQuery(Class<T_RESULT> resultType, Class entityType, Condition<T> condition, List<OrderBy<T>> orderBy) {
this.resultType = resultType;
this.entityType = entityType;
this.condition = condition;
this.orderBy = orderBy;
}
@Override
public Optional<T_RESULT> findFirst() {
return findAll(0, 1).stream().findFirst();
}
@Override
public Optional<T_RESULT> findOne() {
List<T_RESULT> all = findAll(0, 2);
if (all.size() > 1) {
throw new TooManyResultsException();
} else if (all.size() == 1) {
return Optional.of(all.get(0));
} else {
return Optional.empty();
}
}
@Override
public List<T_RESULT> findAll() {
return findAll(0, Integer.MAX_VALUE);
}
@Override
public List<T_RESULT> findAll(long offset, long limit) {
StringBuilder orderByBuilder = new StringBuilder();
if (orderBy != null && !orderBy.isEmpty()) {
toOrderBySQL(orderByBuilder);
}
SQLSelectStatement sqlSelectQuery =
new SQLSelectStatement(
computeFields(),
computeFromTable(),
computeCondition(),
orderByBuilder.toString(),
limit,
offset
);
return executeRead(
sqlSelectQuery,
statement -> {
List<T_RESULT> result = new ArrayList<>();
ResultSet resultSet = statement.executeQuery();
while (resultSet.next()) {
result.add(extractResult(resultSet));
}
return Collections.unmodifiableList(result);
}
);
}
@Override
@SuppressWarnings("unchecked")
public Query<T, Result<T_RESULT>> withIds() {
return new SQLiteQuery<>((Class<Result<T_RESULT>>) (Class<?>) Result.class, resultType, condition, orderBy);
}
@Override
public long count() {
SQLSelectStatement sqlStatementQuery =
new SQLSelectStatement(
List.of(new SQLField("COUNT(*)")),
computeFromTable(),
computeCondition()
);
return executeRead(
sqlStatementQuery,
statement -> {
ResultSet resultSet = statement.executeQuery();
if (resultSet.next()) {
return resultSet.getLong(1);
}
throw new IllegalStateException("failed to read count for type " + queryableTypeDescriptor);
}
);
}
@Override
public Query<T, T_RESULT> orderBy(QueryField<T, ?> field, Order order) {
List<OrderBy<T>> extendedOrderBy = new ArrayList<>(this.orderBy);
extendedOrderBy.add(new OrderBy<>(field, order));
return new SQLiteQuery<>(resultType, entityType, condition, extendedOrderBy);
}
private List<SQLField> computeFields() {
List<SQLField> fields = new ArrayList<>();
fields.add(SQLField.PAYLOAD);
if (resultType.isAssignableFrom(Result.class)) {
addParentIdSQLFields(fields);
}
return fields;
}
private List<SQLNodeWithValue> computeCondition() {
List<SQLNodeWithValue> conditions = new ArrayList<>();
evaluateParentConditions(conditions);
if (condition != null) {
if (condition instanceof LeafCondition<T, ?> leafCondition) {
SQLCondition sqlCondition = SQLConditionMapper.mapToSQLCondition(leafCondition);
conditions.add(sqlCondition);
}
if (condition instanceof LogicalCondition<T> logicalCondition) {
SQLLogicalCondition sqlLogicalCondition = SQLConditionMapper.mapToSQLLogicalCondition(logicalCondition);
conditions.add(sqlLogicalCondition);
}
}
return conditions;
}
private void toOrderBySQL(StringBuilder orderByBuilder) {
Iterator<OrderBy<T>> it = orderBy.iterator();
while (it.hasNext()) {
OrderBy<T> order = it.next();
orderByBuilder.append("json_extract(payload, '$.").append(order.field.getName()).append("') ").append(order.order.name());
if (it.hasNext()) {
orderByBuilder.append(", ");
}
}
}
@SuppressWarnings("unchecked")
private T_RESULT extractResult(ResultSet resultSet) throws JsonProcessingException, SQLException {
T entity = objectMapper.readValue(resultSet.getString(1), entityType);
if (resultType.isAssignableFrom(Result.class)) {
Map<String, String> parentIds = new HashMap<>(queryableTypeDescriptor.getTypes().length);
for (int i = 0; i < queryableTypeDescriptor.getTypes().length; i++) {
parentIds.put(computeColumnIdentifier(queryableTypeDescriptor.getTypes()[i]), resultSet.getString(i + 2));
}
String id = resultSet.getString(queryableTypeDescriptor.getTypes().length + 2);
return (T_RESULT) new Result<T>() {
@Override
public Optional<String> getParentId(Class<?> clazz) {
String parentClassName = computeColumnIdentifier(clazz.getName());
return Optional.ofNullable(parentIds.get(parentClassName));
}
@Override
public String getId() {
return id;
}
@Override
public T getEntity() {
return entity;
}
};
} else {
return (T_RESULT) entity;
}
}
private static <T> Condition<T> conjunct(Condition<T>[] conditions) {
if (conditions.length == 0) {
return null;
} else if (conditions.length == 1) {
return conditions[0];
} else {
return Conditions.and(conditions);
}
}
}
private void evaluateParentConditions(List<SQLNodeWithValue> conditions) {
for (int i = 0; i < parentIds.length; i++) {
SQLCondition condition = new SQLCondition("=", new SQLField(computeColumnIdentifier(queryableTypeDescriptor.getTypes()[i])), new SQLValue(parentIds[i]));
conditions.add(condition);
}
}
private void addParentIdSQLFields(List<SQLField> fields) {
for (int i = 0; i < queryableTypeDescriptor.getTypes().length; i++) {
fields.add(new SQLField(computeColumnIdentifier(queryableTypeDescriptor.getTypes()[i])));
}
fields.add(new SQLField("ID"));
}
interface StatementCallback<R> {
R apply(PreparedStatement statement) throws SQLException, JsonProcessingException;
}
record OrderBy<T>(QueryField<T, ?> field, Order order) {
}
private class TemporaryTableMaintenanceIterator implements MaintenanceIterator<T> {
private final PreparedStatement iterateStatement;
private final List<SQLField> columns;
private final ResultSet resultSet;
private Boolean hasNext;
public TemporaryTableMaintenanceIterator(List<SQLField> columns) {
this.columns = columns;
this.hasNext = null;
SQLSelectStatement iterateQuery =
new SQLSelectStatement(
columns,
computeFromTable(),
computeConditionsForAllValues()
);
String sql = iterateQuery.toSQL();
log.debug("iterating SQL: {}", sql);
try {
iterateStatement = connection.prepareStatement(sql);
iterateQuery.apply(iterateStatement, 1);
resultSet = iterateStatement.executeQuery();
} catch (SQLException e) {
throw new StoreException("Failed to iterate: " + sql, e);
}
createTemporaryTable();
}
private void createTemporaryTable() {
dropTemporaryTable();
StringBuilder tmpTableStatement = new StringBuilder("create table if not exists ").append(TEMPORARY_UPDATE_TABLE_NAME).append(" (");
for (int i = 0; i < queryableTypeDescriptor.getTypes().length; i++) {
tmpTableStatement.append(computeColumnIdentifier(queryableTypeDescriptor.getTypes()[i])).append(" TEXT NOT NULL, ");
}
tmpTableStatement.append("ID TEXT NOT NULL, payload JSONB)");
try (Statement statement = connection.createStatement()) {
String createTableSql = tmpTableStatement.toString();
log.debug("creating table: {}", createTableSql);
statement.execute(createTableSql);
} catch (SQLException e) {
throw new StoreException("Failed to create temporary table: " + tmpTableStatement, e);
}
}
private void dropTemporaryTable() {
String sql = "DROP TABLE IF EXISTS " + TEMPORARY_UPDATE_TABLE_NAME;
try (Statement statement = connection.createStatement()) {
log.trace("dropping table: {}", sql);
statement.executeUpdate(sql);
} catch (SQLException e) {
throw new StoreException("Failed to drop temporary table: " + sql, e);
}
}
@Override
public boolean hasNext() {
if (hasNext != null) {
return hasNext;
}
try {
hasNext = resultSet.next();
return hasNext;
} catch (SQLException e) {
throw new StoreException("Failed to get next row from result set", e);
}
}
@Override
public MaintenanceStoreEntry<T> next() {
if (!hasNext()) {
throw new NoSuchElementException();
}
hasNext = null;
return new InnerStoreEntry();
}
@Override
public void remove() {
List<SQLNodeWithValue> parentConditions = new ArrayList<>();
try {
for (int i = 0; i < queryableTypeDescriptor.getTypes().length; i++) {
String columnName = computeColumnIdentifier(queryableTypeDescriptor.getTypes()[i]);
parentConditions.add(new SQLCondition("=", new SQLField(columnName), new SQLValue(resultSet.getString(columnName))));
}
parentConditions.add(new SQLCondition("=", new SQLField("ID"), new SQLValue(resultSet.getString("ID"))));
} catch (SQLException e) {
throw new StoreException("Failed to delete item from table", e);
}
SQLDeleteStatement sqlStatementQuery =
new SQLDeleteStatement(
computeFromTable(),
parentConditions
);
executeWrite(
sqlStatementQuery,
statement -> {
statement.executeUpdate();
return null;
}
);
}
@Override
public void close() throws Exception {
iterateStatement.close();
SQLSelectStatement tmpIterateQuery =
new SQLSelectStatement(
columns,
new SQLTable(TEMPORARY_UPDATE_TABLE_NAME),
computeConditionsForAllValues()
);
String sql = tmpIterateQuery.toSQL();
log.debug("iterating temporary table: {}", sql);
iterateStatement.close();
try (PreparedStatement tmpIterateStatement = connection.prepareStatement(sql)) {
tmpIterateQuery.apply(tmpIterateStatement, 1);
ResultSet tmpResultSet = tmpIterateStatement.executeQuery();
while (tmpResultSet.next()) {
Collection<String> allParentIds = computeAllParentIds(tmpResultSet);
writeJsonInTable(
computeFromTable(),
allParentIds,
tmpResultSet.getString(queryableTypeDescriptor.getTypes().length + 2),
tmpResultSet.getString(1)
);
}
} catch (SQLException e) {
throw new StoreException("Failed to transfer entries from temporary table", e);
}
dropTemporaryTable();
}
private List<String> computeAllParentIds(ResultSet tmpResultSet) throws SQLException {
List<String> allParentIds = new ArrayList<>();
for (int columnNr = 0; columnNr < queryableTypeDescriptor.getTypes().length; ++columnNr) {
allParentIds.add(tmpResultSet.getString(columnNr + 2));
}
return allParentIds;
}
private void writeJsonInTable(SQLTable table, Collection<String> allParentIds, String id, String json) {
List<String> columnsToInsert = new ArrayList<>(allParentIds);
columnsToInsert.add(id);
columnsToInsert.add(json);
SQLInsertStatement sqlInsertStatement =
new SQLInsertStatement(
table,
new SQLValue(columnsToInsert)
);
executeWrite(
sqlInsertStatement,
statement -> {
statement.executeUpdate();
return null;
}
);
}
private class InnerStoreEntry implements MaintenanceStoreEntry<T> {
private final Map<String, String> parentIds = new LinkedHashMap<>();
private final String id;
private final String json;
InnerStoreEntry() {
try {
json = resultSet.getString(1);
for (int i = 0; i < queryableTypeDescriptor.getTypes().length; i++) {
parentIds.put(computeColumnIdentifier(queryableTypeDescriptor.getTypes()[i]), resultSet.getString(i + 2));
}
id = resultSet.getString(queryableTypeDescriptor.getTypes().length + 2);
} catch (SQLException e) {
throw new StoreException("Failed to read next entry for maintenance", e);
}
}
@Override
public String getId() {
return id;
}
@Override
public Optional<String> getParentId(Class<?> clazz) {
String parentClassName = computeColumnIdentifier(clazz.getName());
return Optional.ofNullable(parentIds.get(parentClassName));
}
@Override
public T get() {
return getAs(clazz);
}
@Override
public <U> U getAs(Class<U> type) {
try {
return objectMapper.readValue(json, type);
} catch (JsonProcessingException e) {
throw new SerializationException("failed to read object from json", e);
}
}
void updateJson(String json) {
SQLTable table = new SQLTable(TEMPORARY_UPDATE_TABLE_NAME);
writeJsonInTable(table, parentIds.values(), id, json);
}
@Override
public void update(Object object) {
updateJson(serialize(object));
}
}
}
private String serialize(Object object) {
try {
return objectMapper.writeValueAsString(object);
} catch (JsonProcessingException e) {
throw new SerializationException("failed to serialize object to json", e);
}
}
private interface RowBuilder<R> {
R build(String[] parentIds, String id, String json) throws JsonProcessingException;
}
}

View File

@@ -0,0 +1,136 @@
/*
* Copyright (c) 2020 - present Cloudogu GmbH
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3.
*
* 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 Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
package sonia.scm.store.sqlite;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.common.annotations.VisibleForTesting;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
import lombok.extern.slf4j.Slf4j;
import org.sqlite.SQLiteConfig;
import org.sqlite.SQLiteDataSource;
import sonia.scm.SCMContextProvider;
import sonia.scm.plugin.PluginLoader;
import sonia.scm.plugin.QueryableTypeDescriptor;
import sonia.scm.security.KeyGenerator;
import sonia.scm.store.QueryableMaintenanceStore;
import sonia.scm.store.QueryableStoreFactory;
import sonia.scm.store.StoreException;
import javax.sql.DataSource;
import java.nio.file.Path;
import java.sql.Connection;
import java.sql.SQLException;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
import static com.fasterxml.jackson.databind.DeserializationFeature.READ_DATE_TIMESTAMPS_AS_NANOSECONDS;
import static com.fasterxml.jackson.databind.SerializationFeature.WRITE_DATES_AS_TIMESTAMPS;
import static com.fasterxml.jackson.databind.SerializationFeature.WRITE_DATE_TIMESTAMPS_AS_NANOSECONDS;
@Slf4j
@Singleton
public class SQLiteQueryableStoreFactory implements QueryableStoreFactory {
private final ObjectMapper objectMapper;
private final KeyGenerator keyGenerator;
private final DataSource dataSource;
private final Map<String, QueryableTypeDescriptor> queryableTypes = new HashMap<>();
private final ReadWriteLock lock = new LoggingReadWriteLock(new ReentrantReadWriteLock());
@Inject
public SQLiteQueryableStoreFactory(SCMContextProvider contextProvider,
PluginLoader pluginLoader,
ObjectMapper objectMapper,
KeyGenerator keyGenerator) {
this(
"jdbc:sqlite:" + contextProvider.resolve(Path.of("scm.db")),
objectMapper
.copy()
.configure(WRITE_DATES_AS_TIMESTAMPS, true)
.configure(WRITE_DATE_TIMESTAMPS_AS_NANOSECONDS, false)
.configure(READ_DATE_TIMESTAMPS_AS_NANOSECONDS, false),
keyGenerator,
pluginLoader.getExtensionProcessor().getQueryableTypes()
);
}
@VisibleForTesting
public SQLiteQueryableStoreFactory(String connectionString,
ObjectMapper objectMapper,
KeyGenerator keyGenerator,
Iterable<QueryableTypeDescriptor> queryableTypeIterable) {
SQLiteConfig config = new SQLiteConfig();
config.setSharedCache(true);
config.setJournalMode(SQLiteConfig.JournalMode.WAL);
this.dataSource = new SQLiteDataSource(
config
);
((SQLiteDataSource) dataSource).setUrl(connectionString);
this.objectMapper = objectMapper;
this.keyGenerator = keyGenerator;
Connection connection = openDefaultConnection();
try {
TableCreator tableCreator = new TableCreator(connection);
for (QueryableTypeDescriptor queryableTypeDescriptor : queryableTypeIterable) {
queryableTypes.put(queryableTypeDescriptor.getClazz(), queryableTypeDescriptor);
tableCreator.initializeTable(queryableTypeDescriptor);
}
} finally {
try {
connection.close();
} catch (SQLException e) {
log.warn("could not close connection", e);
}
}
}
private Connection openDefaultConnection() {
try {
log.debug("open connection");
Connection connection = dataSource.getConnection();
connection.setAutoCommit(true);
return connection;
} catch (SQLException e) {
throw new StoreException("could not connect to database", e);
}
}
@Override
public <T> SQLiteQueryableStore<T> getReadOnly(Class<T> clazz, String... parentIds) {
return new SQLiteQueryableStore<>(objectMapper, openDefaultConnection(), clazz, getQueryableTypeDescriptor(clazz), parentIds, lock);
}
@Override
public <T> QueryableMaintenanceStore<T> getForMaintenance(Class<T> clazz, String... parentIds) {
return new SQLiteQueryableStore<>(objectMapper, openDefaultConnection(), clazz, getQueryableTypeDescriptor(clazz), parentIds, lock);
}
@Override
public <T> SQLiteQueryableMutableStore<T> getMutable(Class<T> clazz, String... parentIds) {
return new SQLiteQueryableMutableStore<>(objectMapper, keyGenerator, openDefaultConnection(), clazz, getQueryableTypeDescriptor(clazz), parentIds, lock);
}
private <T> QueryableTypeDescriptor getQueryableTypeDescriptor(Class<T> clazz) {
return queryableTypes.get(clazz.getName().replace('$', '.'));
}
}

View File

@@ -0,0 +1,75 @@
/*
* Copyright (c) 2020 - present Cloudogu GmbH
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3.
*
* 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 Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
package sonia.scm.store.sqlite;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
import lombok.extern.slf4j.Slf4j;
import sonia.scm.plugin.PluginLoader;
import sonia.scm.plugin.QueryableTypeDescriptor;
import sonia.scm.store.StoreException;
import sonia.scm.store.StoreMetaDataProvider;
import java.util.Arrays;
import java.util.Collection;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
@Slf4j
@Singleton
public class SQLiteStoreMetaDataProvider implements StoreMetaDataProvider {
private final Map<Collection<String>, Collection<Class<?>>> typesForParents = new HashMap<>();
private final ClassLoader classLoader;
@Inject
SQLiteStoreMetaDataProvider(PluginLoader pluginLoader) {
classLoader = pluginLoader.getUberClassLoader();
Iterable<QueryableTypeDescriptor> queryableTypes = pluginLoader.getExtensionProcessor().getQueryableTypes();
queryableTypes.forEach(this::initializeType);
}
private void initializeType(QueryableTypeDescriptor descriptor) {
for (int i = 0; i < descriptor.getTypes().length; i++) {
Collection<String> parentClasses =
Arrays.stream(Arrays.copyOf(descriptor.getTypes(), i + 1))
.map(SQLiteStoreMetaDataProvider::removeTrailingClass)
.toList();
Collection<Class<?>> classes = typesForParents.computeIfAbsent(parentClasses, k -> new LinkedList<>());
try {
classes.add(classLoader.loadClass(descriptor.getClazz()));
} catch (ClassNotFoundException e) {
throw new StoreException("Failed to load class '" + descriptor.getClazz() + "' for queryable type descriptor " + descriptor.getName(), e);
}
}
}
private static String removeTrailingClass(String parentClass) {
return parentClass.endsWith(".class") ? parentClass.substring(0, parentClass.length() - ".class".length()) : parentClass;
}
@Override
public Collection<Class<?>> getTypesWithParent(Class<?>... classes) {
Collection<String> classNames =
Arrays.stream(classes)
.map(Class::getName)
.toList();
return typesForParents.getOrDefault(classNames, List.of());
}
}

View File

@@ -0,0 +1,109 @@
/*
* Copyright (c) 2020 - present Cloudogu GmbH
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3.
*
* 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 Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
package sonia.scm.store.sqlite;
import lombok.extern.slf4j.Slf4j;
import sonia.scm.plugin.QueryableTypeDescriptor;
import sonia.scm.store.StoreException;
import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.Collection;
import java.util.LinkedList;
import static sonia.scm.store.sqlite.SQLiteIdentifiers.computeColumnIdentifier;
import static sonia.scm.store.sqlite.SQLiteIdentifiers.computeTableName;
@Slf4j
class TableCreator {
private final Connection connection;
TableCreator(Connection connection) {
this.connection = connection;
}
void initializeTable(QueryableTypeDescriptor descriptor) {
log.info("initializing table for {}", descriptor);
String tableName = computeTableName(descriptor);
Collection<String> columns = getColumns(tableName);
if (columns.isEmpty()) {
createTable(descriptor, tableName);
} else if (!columns.contains("ID")) {
log.error("table {} exists but does not contain ID column", tableName);
throw new StoreException("Table " + tableName + " exists but does not contain ID column");
} else if (!columns.contains("payload")) {
log.error("table {} exists but does not contain payload column", tableName);
throw new StoreException("Table " + tableName + " exists but does not contain payload column");
} else {
for (String type : descriptor.getTypes()) {
String column = computeColumnIdentifier(type);
if (!columns.contains(column)) {
log.error("table {} exists but does not contain column {}", tableName, column);
throw new StoreException("Table " + tableName + " exists but does not contain column " + column);
}
}
if (descriptor.getTypes().length != columns.size() - 2) {
log.error("table {} exists but has too many columns", tableName);
throw new StoreException("Table " + tableName + " exists but has too many columns");
}
}
}
private void createTable(QueryableTypeDescriptor descriptor, String tableName) {
StringBuilder builder = new StringBuilder("CREATE TABLE ")
.append(tableName)
.append(" (");
for (String type : descriptor.getTypes()) {
builder.append(computeColumnIdentifier(type)).append(" TEXT NOT NULL, ");
}
builder.append("ID TEXT NOT NULL, payload JSONB");
builder.append(", PRIMARY KEY (");
for (String type : descriptor.getTypes()) {
builder.append(computeColumnIdentifier(type)).append(", ");
}
builder.append("ID)");
builder.append(')');
try {
log.info("creating table {} for {}", tableName, descriptor);
log.trace("sql: {}", builder);
boolean result = connection.createStatement().execute(builder.toString());
log.trace("created: {}", result);
} catch (SQLException e) {
throw new StoreException("Failed to create table for class " + descriptor.getClazz() + ": " + builder, e);
}
}
Collection<String> getColumns(String tableName) {
log.debug("checking table {}", tableName);
try {
ResultSet resultSet = connection.createStatement().executeQuery("PRAGMA table_info(" + tableName + ")");
Collection<String> columns = new LinkedList<>();
while (resultSet.next()) {
columns.add(resultSet.getString("name"));
}
resultSet.close();
return columns;
} catch (SQLException e) {
throw new StoreException("Failed to get columns for table " + tableName, e);
}
}
}

View File

@@ -0,0 +1,46 @@
/*
* Copyright (c) 2020 - present Cloudogu GmbH
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3.
*
* 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 Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
package sonia.scm.update.xml;
import jakarta.inject.Inject;
import sonia.scm.store.ConfigurationEntryStore;
import sonia.scm.store.ConfigurationEntryStoreFactory;
import sonia.scm.update.V1Properties;
import sonia.scm.update.V1PropertyDAO;
import sonia.scm.update.V1PropertyReader;
import java.util.Map;
public class XmlV1PropertyDAO implements V1PropertyDAO {
private final ConfigurationEntryStoreFactory configurationEntryStoreFactory;
@Inject
public XmlV1PropertyDAO(ConfigurationEntryStoreFactory configurationEntryStoreFactory) {
this.configurationEntryStoreFactory = configurationEntryStoreFactory;
}
@Override
public V1PropertyReader.Instance getProperties(V1PropertyReader reader) {
ConfigurationEntryStore<V1Properties> propertyStore = configurationEntryStoreFactory
.withType(V1Properties.class)
.withName(reader.getStoreName())
.build();
Map<String, V1Properties> all = propertyStore.getAll();
return reader.createInstance(all);
}
}

View File

@@ -0,0 +1,60 @@
/*
* Copyright (c) 2020 - present Cloudogu GmbH
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3.
*
* 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 Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
package sonia.scm.user.xml;
import com.google.inject.Inject;
import com.google.inject.Singleton;
import sonia.scm.store.ConfigurationStoreFactory;
import sonia.scm.user.User;
import sonia.scm.user.UserDAO;
import sonia.scm.xml.AbstractXmlDAO;
@Singleton
public class XmlUserDAO extends AbstractXmlDAO<User, XmlUserDatabase>
implements UserDAO
{
public static final String STORE_NAME = "users";
@Inject
public XmlUserDAO(ConfigurationStoreFactory storeFactory)
{
super(storeFactory
.withType(XmlUserDatabase.class)
.withName(STORE_NAME)
.build());
}
@Override
protected User clone(User user)
{
return user.clone();
}
@Override
protected XmlUserDatabase createNewDatabase()
{
return new XmlUserDatabase();
}
}

View File

@@ -0,0 +1,120 @@
/*
* Copyright (c) 2020 - present Cloudogu GmbH
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3.
*
* 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 Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
package sonia.scm.user.xml;
import jakarta.xml.bind.annotation.XmlAccessType;
import jakarta.xml.bind.annotation.XmlAccessorType;
import jakarta.xml.bind.annotation.XmlElement;
import jakarta.xml.bind.annotation.XmlRootElement;
import jakarta.xml.bind.annotation.adapters.XmlJavaTypeAdapter;
import sonia.scm.auditlog.AuditEntry;
import sonia.scm.user.User;
import sonia.scm.xml.XmlDatabase;
import java.util.Collection;
import java.util.Map;
import java.util.TreeMap;
@AuditEntry(ignore = true)
@XmlRootElement(name = "user-db")
@XmlAccessorType(XmlAccessType.FIELD)
public class XmlUserDatabase implements XmlDatabase<User>
{
private Long creationTime;
private Long lastModified;
@XmlJavaTypeAdapter(XmlUserMapAdapter.class)
@XmlElement(name = "users")
private Map<String, User> userMap = new TreeMap<>();
public XmlUserDatabase()
{
long c = System.currentTimeMillis();
creationTime = c;
lastModified = c;
}
@Override
public void add(User user)
{
userMap.put(user.getName(), user);
}
@Override
public boolean contains(String username)
{
return userMap.containsKey(username);
}
@Override
public User remove(String username)
{
return userMap.remove(username);
}
@Override
public Collection<User> values()
{
return userMap.values();
}
@Override
public User get(String username)
{
return userMap.get(username);
}
@Override
public long getCreationTime()
{
return creationTime;
}
@Override
public long getLastModified()
{
return lastModified;
}
@Override
public void setCreationTime(long creationTime)
{
this.creationTime = creationTime;
}
@Override
public void setLastModified(long lastModified)
{
this.lastModified = lastModified;
}
}

View File

@@ -0,0 +1,68 @@
/*
* Copyright (c) 2020 - present Cloudogu GmbH
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3.
*
* 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 Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
package sonia.scm.user.xml;
import jakarta.xml.bind.annotation.XmlAccessType;
import jakarta.xml.bind.annotation.XmlAccessorType;
import jakarta.xml.bind.annotation.XmlElement;
import jakarta.xml.bind.annotation.XmlRootElement;
import sonia.scm.user.User;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.Map;
@XmlRootElement(name = "users")
@XmlAccessorType(XmlAccessType.FIELD)
public class XmlUserList implements Iterable<User>
{
@XmlElement(name = "user")
private LinkedList<User> users;
public XmlUserList() {}
public XmlUserList(Map<String, User> userMap)
{
this.users = new LinkedList<>(userMap.values());
}
@Override
public Iterator<User> iterator()
{
return users.iterator();
}
public LinkedList<User> getUsers()
{
return users;
}
public void setUsers(LinkedList<User> users)
{
this.users = users;
}
}

View File

@@ -0,0 +1,50 @@
/*
* Copyright (c) 2020 - present Cloudogu GmbH
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3.
*
* 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 Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
package sonia.scm.user.xml;
import jakarta.xml.bind.annotation.adapters.XmlAdapter;
import sonia.scm.user.User;
import java.util.Map;
import java.util.TreeMap;
public class XmlUserMapAdapter
extends XmlAdapter<XmlUserList, Map<String, User>>
{
@Override
public XmlUserList marshal(Map<String, User> userMap) throws Exception
{
return new XmlUserList(userMap);
}
@Override
public Map<String, User> unmarshal(XmlUserList users) throws Exception
{
Map<String, User> userMap = new TreeMap<>();
for (User user : users)
{
userMap.put(user.getName(), user);
}
return userMap;
}
}

View File

@@ -0,0 +1,175 @@
/*
* Copyright (c) 2020 - present Cloudogu GmbH
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3.
*
* 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 Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
package sonia.scm.xml;
import com.google.common.collect.ImmutableList;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import sonia.scm.GenericDAO;
import sonia.scm.ModelObject;
import sonia.scm.store.ConfigurationStore;
import java.util.Collection;
public abstract class AbstractXmlDAO<I extends ModelObject,
T extends XmlDatabase<I>> implements GenericDAO<I>
{
public static final String TYPE = "xml";
private static final Logger logger =
LoggerFactory.getLogger(AbstractXmlDAO.class);
protected final ConfigurationStore<T> store;
protected T db;
public AbstractXmlDAO(ConfigurationStore<T> store)
{
this.store = store;
db = store.get();
if (db == null)
{
db = createNewDatabase();
}
}
protected abstract I clone(I item);
protected abstract T createNewDatabase();
@Override
public void add(I item)
{
if (logger.isTraceEnabled())
{
logger.trace("add item {} to xml backend", item.getId());
}
synchronized (store)
{
db.add(clone(item));
storeDB();
}
}
@Override
public boolean contains(I item)
{
return contains(item.getId());
}
@Override
public boolean contains(String id)
{
return db.contains(id);
}
@Override
public void delete(I item)
{
if (logger.isTraceEnabled())
{
logger.trace("delete item {} from xml backend", item.getId());
}
synchronized (store)
{
db.remove(item.getId());
storeDB();
}
}
@Override
@SuppressWarnings("unchecked")
public void modify(I item)
{
if (logger.isTraceEnabled())
{
logger.trace("modify xml backend item {}", item.getId());
}
synchronized (store)
{
db.remove(item.getId());
db.add(clone(item));
storeDB();
}
}
@Override
@SuppressWarnings("unchecked")
public I get(String id)
{
return (I) db.get(id);
}
@Override
public Collection<I> getAll()
{
// avoid concurrent modification exceptions
return ImmutableList.copyOf(db.values());
}
@Override
public Long getCreationTime()
{
return db.getCreationTime();
}
@Override
public Long getLastModified()
{
return db.getLastModified();
}
@Override
public String getType()
{
return TYPE;
}
protected void storeDB()
{
if (logger.isTraceEnabled())
{
logger.trace("store xml database");
}
db.setLastModified(System.currentTimeMillis());
store.set(db);
}
}

View File

@@ -0,0 +1,53 @@
/*
* Copyright (c) 2020 - present Cloudogu GmbH
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3.
*
* 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 Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
package sonia.scm.xml;
import java.util.Collection;
public interface XmlDatabase<T>
{
public void add(T item);
public boolean contains(String id);
public T remove(String id);
public Collection<T> values();
public T get(String id);
public long getCreationTime();
public long getLastModified();
public void setCreationTime(long creationTime);
public void setLastModified(long lastModified);
}

View File

@@ -0,0 +1,288 @@
/*
* Copyright (c) 2020 - present Cloudogu GmbH
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3.
*
* 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 Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
package sonia.scm.xml;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.xml.namespace.NamespaceContext;
import javax.xml.stream.XMLInputFactory;
import javax.xml.stream.XMLOutputFactory;
import javax.xml.stream.XMLStreamException;
import javax.xml.stream.XMLStreamReader;
import javax.xml.stream.XMLStreamWriter;
import javax.xml.stream.util.StreamReaderDelegate;
import java.io.Closeable;
import java.io.File;
import java.io.IOException;
import java.io.Reader;
import java.io.Writer;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
public final class XmlStreams {
private static final Logger LOG = LoggerFactory.getLogger(XmlStreams.class);
private XmlStreams() {
}
public static void close(XMLStreamWriter writer) {
if (writer != null) {
try {
writer.close();
} catch (XMLStreamException ex) {
LOG.error("could not close writer", ex);
}
}
}
public static void close(XMLStreamReader reader) {
if (reader != null) {
try {
reader.close();
} catch (XMLStreamException ex) {
LOG.error("could not close reader", ex);
}
}
}
public static AutoCloseableXMLReader createReader(Path path) throws IOException, XMLStreamException {
return createReader(Files.newBufferedReader(path, StandardCharsets.UTF_8));
}
public static AutoCloseableXMLReader createReader(File file) throws IOException, XMLStreamException {
return createReader(file.toPath());
}
private static AutoCloseableXMLReader createReader(Reader reader) throws XMLStreamException {
XMLInputFactory factory = XMLInputFactory.newInstance();
factory.setProperty(XMLInputFactory.IS_SUPPORTING_EXTERNAL_ENTITIES, Boolean.FALSE);
XMLStreamReader xmlStreamReader = factory.createXMLStreamReader(reader);
return new AutoCloseableXMLReader(xmlStreamReader, reader);
}
public static AutoCloseableXMLWriter createWriter(Path path) throws IOException, XMLStreamException {
return createWriter(Files.newBufferedWriter(path, StandardCharsets.UTF_8));
}
public static AutoCloseableXMLWriter createWriter(File file) throws IOException, XMLStreamException {
return createWriter(file.toPath());
}
private static AutoCloseableXMLWriter createWriter(Writer writer) throws XMLStreamException {
IndentXMLStreamWriter indentXMLStreamWriter = new IndentXMLStreamWriter(XMLOutputFactory.newFactory().createXMLStreamWriter(writer));
FilterInvalidCharXMLStreamWriter filterInvalidCharXMLStreamWriter = new FilterInvalidCharXMLStreamWriter(indentXMLStreamWriter);
return new AutoCloseableXMLWriter(filterInvalidCharXMLStreamWriter, writer);
}
public static final class AutoCloseableXMLReader extends StreamReaderDelegate implements AutoCloseable {
private final Closeable closeable;
public AutoCloseableXMLReader(XMLStreamReader delegate, Closeable closeable) {
super(delegate);
this.closeable = closeable;
}
@Override
public void close() throws XMLStreamException {
super.close();
try {
closeable.close();
} catch (IOException e) {
throw new XMLStreamException("failed to close nested reader", e);
}
}
}
public static final class AutoCloseableXMLWriter implements XMLStreamWriter, AutoCloseable {
private final XMLStreamWriter delegate;
private final Closeable closeable;
public AutoCloseableXMLWriter(XMLStreamWriter delegate, Closeable closeable) {
this.delegate = delegate;
this.closeable = closeable;
}
@Override
public void writeStartElement(String localName) throws XMLStreamException {
delegate.writeStartElement(localName);
}
@Override
public void writeStartElement(String namespaceURI, String localName) throws XMLStreamException {
delegate.writeStartElement(namespaceURI, localName);
}
@Override
public void writeStartElement(String prefix, String localName, String namespaceURI) throws XMLStreamException {
delegate.writeStartElement(prefix, localName, namespaceURI);
}
@Override
public void writeEmptyElement(String namespaceURI, String localName) throws XMLStreamException {
delegate.writeEmptyElement(namespaceURI, localName);
}
@Override
public void writeEmptyElement(String prefix, String localName, String namespaceURI) throws XMLStreamException {
delegate.writeEmptyElement(prefix, localName, namespaceURI);
}
@Override
public void writeEmptyElement(String localName) throws XMLStreamException {
delegate.writeEmptyElement(localName);
}
@Override
public void writeEndElement() throws XMLStreamException {
delegate.writeEndElement();
}
@Override
public void writeEndDocument() throws XMLStreamException {
delegate.writeEndDocument();
}
@Override
public void close() throws XMLStreamException {
delegate.close();
try {
closeable.close();
} catch (IOException e) {
throw new XMLStreamException("failed to close nested writer", e);
}
}
@Override
public void flush() throws XMLStreamException {
delegate.flush();
}
@Override
public void writeAttribute(String localName, String value) throws XMLStreamException {
delegate.writeAttribute(localName, value);
}
@Override
public void writeAttribute(String prefix, String namespaceURI, String localName, String value) throws XMLStreamException {
delegate.writeAttribute(prefix, namespaceURI, localName, value);
}
@Override
public void writeAttribute(String namespaceURI, String localName, String value) throws XMLStreamException {
delegate.writeAttribute(namespaceURI, localName, value);
}
@Override
public void writeNamespace(String prefix, String namespaceURI) throws XMLStreamException {
delegate.writeNamespace(prefix, namespaceURI);
}
@Override
public void writeDefaultNamespace(String namespaceURI) throws XMLStreamException {
delegate.writeDefaultNamespace(namespaceURI);
}
@Override
public void writeComment(String data) throws XMLStreamException {
delegate.writeComment(data);
}
@Override
public void writeProcessingInstruction(String target) throws XMLStreamException {
delegate.writeProcessingInstruction(target);
}
@Override
public void writeProcessingInstruction(String target, String data) throws XMLStreamException {
delegate.writeProcessingInstruction(target, data);
}
@Override
public void writeCData(String data) throws XMLStreamException {
delegate.writeCData(data);
}
@Override
public void writeDTD(String dtd) throws XMLStreamException {
delegate.writeDTD(dtd);
}
@Override
public void writeEntityRef(String name) throws XMLStreamException {
delegate.writeEntityRef(name);
}
@Override
public void writeStartDocument() throws XMLStreamException {
delegate.writeStartDocument();
}
@Override
public void writeStartDocument(String version) throws XMLStreamException {
delegate.writeStartDocument(version);
}
@Override
public void writeStartDocument(String encoding, String version) throws XMLStreamException {
delegate.writeStartDocument(encoding, version);
}
@Override
public void writeCharacters(String text) throws XMLStreamException {
delegate.writeCharacters(text);
}
@Override
public void writeCharacters(char[] text, int start, int len) throws XMLStreamException {
delegate.writeCharacters(text, start, len);
}
@Override
public String getPrefix(String uri) throws XMLStreamException {
return delegate.getPrefix(uri);
}
@Override
public void setPrefix(String prefix, String uri) throws XMLStreamException {
delegate.setPrefix(prefix, uri);
}
@Override
public void setDefaultNamespace(String uri) throws XMLStreamException {
delegate.setDefaultNamespace(uri);
}
@Override
public void setNamespaceContext(NamespaceContext context) throws XMLStreamException {
delegate.setNamespaceContext(context);
}
@Override
public NamespaceContext getNamespaceContext() {
return delegate.getNamespaceContext();
}
@Override
public Object getProperty(String name) throws IllegalArgumentException {
return delegate.getProperty(name);
}
}
}

View File

@@ -0,0 +1,257 @@
/*
* Copyright (c) 2020 - present Cloudogu GmbH
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3.
*
* 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 Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
package sonia.scm.repository.xml;
import com.google.common.base.Charsets;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.Mock;
import org.mockito.Spy;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import sonia.scm.SCMContextProvider;
import sonia.scm.io.DefaultFileSystem;
import sonia.scm.io.FileSystem;
import sonia.scm.repository.InitialRepositoryLocationResolver;
import sonia.scm.repository.RepositoryLocationResolver;
import sonia.scm.repository.RepositoryLocationResolver.RepositoryLocationResolverInstance;
import sonia.scm.repository.xml.PathBasedRepositoryLocationResolver.DownForMaintenanceContext;
import sonia.scm.repository.xml.PathBasedRepositoryLocationResolver.UpAfterMaintenanceContext;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.PosixFilePermission;
import java.time.Clock;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class PathBasedRepositoryLocationResolverTest {
private static final long CREATION_TIME = 42;
@Mock
private SCMContextProvider contextProvider;
@Mock
private InitialRepositoryLocationResolver initialRepositoryLocationResolver;
@Mock
private Clock clock;
private final FileSystem fileSystem = new DefaultFileSystem();
private Path basePath;
private PathBasedRepositoryLocationResolver resolver;
@BeforeEach
void beforeEach(@TempDir Path temp) {
this.basePath = temp;
when(contextProvider.getBaseDirectory()).thenReturn(temp.toFile());
when(contextProvider.resolve(any(Path.class))).thenAnswer(invocation -> invocation.getArgument(0));
when(initialRepositoryLocationResolver.getPath(anyString())).thenAnswer(invocation -> temp.resolve(invocation.getArgument(0).toString()));
when(clock.millis()).thenReturn(CREATION_TIME);
resolver = createResolver();
}
@Test
void shouldCreateInitialDirectory() {
Path path = resolver.forClass(Path.class).createLocation("newId");
assertThat(path).isEqualTo(basePath.resolve("newId"));
assertThat(path).isDirectory();
}
@Test
void shouldFailIfDirectoryExists() throws IOException {
Files.createDirectories(basePath.resolve("newId"));
RepositoryLocationResolverInstance<Path> resolverInstance = resolver.forClass(Path.class);
assertThatThrownBy(() -> resolverInstance.createLocation("newId"))
.isInstanceOf(RepositoryLocationResolver.RepositoryStorageException.class);
}
@Test
void shouldPersistInitialDirectory() {
resolver.forClass(Path.class).createLocation("newId");
String content = getXmlFileContent();
assertThat(content).contains("newId");
assertThat(content).contains(basePath.resolve("newId").toString());
}
@Test
void shouldPersistWithCreationDate() {
long now = CREATION_TIME + 100;
when(clock.millis()).thenReturn(now);
resolver.forClass(Path.class).createLocation("newId");
assertThat(resolver.getCreationTime()).isEqualTo(CREATION_TIME);
String content = getXmlFileContent();
assertThat(content).contains("creation-time=\"" + CREATION_TIME + "\"");
}
@Test
void shouldUpdateWithModifiedDate() {
long now = CREATION_TIME + 100;
when(clock.millis()).thenReturn(now);
resolver.forClass(Path.class).createLocation("newId");
assertThat(resolver.getCreationTime()).isEqualTo(CREATION_TIME);
assertThat(resolver.getLastModified()).isEqualTo(now);
String content = getXmlFileContent();
assertThat(content).contains("creation-time=\"" + CREATION_TIME + "\"");
assertThat(content).contains("last-modified=\"" + now + "\"");
}
@Nested
class WithExistingData {
private PathBasedRepositoryLocationResolver resolverWithExistingData;
@Spy
private PathBasedRepositoryLocationResolver.MaintenanceCallback maintenanceCallback;
@BeforeEach
void createExistingDatabase() {
resolver.forClass(Path.class).createLocation("existingId_1");
resolver.forClass(Path.class).createLocation("existingId_2");
resolverWithExistingData = createResolver();
resolverWithExistingData.registerMaintenanceCallback(maintenanceCallback);
}
@Test
void shouldInitWithExistingData() {
Map<String, Path> foundRepositories = new HashMap<>();
resolverWithExistingData.forClass(Path.class).forAllLocations(
foundRepositories::put
);
assertThat(foundRepositories)
.containsKeys("existingId_1", "existingId_2");
}
@Test
void shouldRemoveFromFile() {
resolverWithExistingData.remove("existingId_1");
assertThat(getXmlFileContent()).doesNotContain("existingId_1");
}
@Test
void shouldNotUpdateModificationDateForExistingDirectoryMapping() {
long now = CREATION_TIME + 100;
Path path = resolverWithExistingData.create(Path.class).getLocation("existingId_1");
assertThat(path).isEqualTo(basePath.resolve("existingId_1"));
String content = getXmlFileContent();
assertThat(content).doesNotContain("last-modified=\"" + now + "\"");
}
@Test
void shouldNotCreateDirectoryForExistingMapping() throws IOException {
Files.delete(basePath.resolve("existingId_1"));
Path path = resolverWithExistingData.create(Path.class).getLocation("existingId_1");
assertThat(path).doesNotExist();
}
@Test
void shouldModifyLocation() throws IOException {
Path oldPath = resolverWithExistingData.create(Path.class).getLocation("existingId_1");
Path newPath = basePath.resolve("modified_location");
resolverWithExistingData.create(Path.class).modifyLocation("existingId_1", newPath);
assertThat(newPath).exists();
assertThat(oldPath).doesNotExist();
assertThat(resolverWithExistingData.create(Path.class).getLocation("existingId_1")).isEqualTo(newPath);
verify(maintenanceCallback).downForMaintenance(new DownForMaintenanceContext("existingId_1"));
verify(maintenanceCallback).upAfterMaintenance(new UpAfterMaintenanceContext("existingId_1", newPath));
}
@Test
void shouldModifyLocationAndKeepOld() throws IOException {
Path oldPath = resolverWithExistingData.create(Path.class).getLocation("existingId_1");
Path newPath = basePath.resolve("modified_location");
resolverWithExistingData.create(Path.class).modifyLocationAndKeepOld("existingId_1", newPath);
assertThat(newPath).exists();
assertThat(oldPath).exists();
assertThat(resolverWithExistingData.create(Path.class).getLocation("existingId_1")).isEqualTo(newPath);
verify(maintenanceCallback).downForMaintenance(new DownForMaintenanceContext("existingId_1"));
verify(maintenanceCallback).upAfterMaintenance(new UpAfterMaintenanceContext("existingId_1", newPath));
}
@Test
void shouldHandleErrorOnModifyLocation() throws IOException {
Path oldPath = resolverWithExistingData.create(Path.class).getLocation("existingId_1");
Path newPath = basePath.resolve("thou").resolve("shall").resolve("not").resolve("move").resolve("here");
Files.createDirectories(newPath);
Files.setPosixFilePermissions(newPath, Set.of(PosixFilePermission.OWNER_READ));
assertThatThrownBy(() -> resolverWithExistingData.create(Path.class).modifyLocationAndKeepOld("existingId_1", newPath))
.isInstanceOf(RepositoryLocationResolver.RepositoryStorageException.class);
assertThat(newPath).exists();
assertThat(oldPath).exists();
assertThat(resolverWithExistingData.create(Path.class).getLocation("existingId_1")).isEqualTo(oldPath);
}
}
private String getXmlFileContent() {
Path storePath = basePath.resolve("config").resolve("repository-paths.xml");
assertThat(storePath).isRegularFile();
return content(storePath);
}
private PathBasedRepositoryLocationResolver createResolver() {
return new PathBasedRepositoryLocationResolver(contextProvider, initialRepositoryLocationResolver, fileSystem, clock);
}
private String content(Path storePath) {
try {
return new String(Files.readAllBytes(storePath), Charsets.UTF_8);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}

View File

@@ -0,0 +1,110 @@
/*
* Copyright (c) 2020 - present Cloudogu GmbH
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3.
*
* 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 Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
package sonia.scm.repository.xml;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.Timeout;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import sonia.scm.SCMContextProvider;
import sonia.scm.io.DefaultFileSystem;
import sonia.scm.io.FileSystem;
import sonia.scm.repository.InitialRepositoryLocationResolver;
import sonia.scm.repository.Repository;
import sonia.scm.repository.RepositoryExportingCheck;
import java.nio.file.Path;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import static java.util.Collections.emptySet;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class XmlRepositoryDAOSynchronizationTest {
private static final int CREATION_COUNT = 100;
private static final long TIMEOUT = 10L;
@Mock
private SCMContextProvider provider;
@Mock
private RepositoryExportingCheck repositoryExportingCheck;
private FileSystem fileSystem;
private PathBasedRepositoryLocationResolver resolver;
private XmlRepositoryDAO repositoryDAO;
@BeforeEach
void setUpObjectUnderTest(@TempDir Path path) {
when(provider.getBaseDirectory()).thenReturn(path.toFile());
when(provider.resolve(any())).then(ic -> {
Path args = ic.getArgument(0);
return path.resolve(args);
});
fileSystem = new DefaultFileSystem();
resolver = new PathBasedRepositoryLocationResolver(
provider, new InitialRepositoryLocationResolver(emptySet()), fileSystem
);
repositoryDAO = new XmlRepositoryDAO(resolver, fileSystem, repositoryExportingCheck);
}
@Test
@Timeout(TIMEOUT)
void shouldCreateALotOfRepositoriesInSerial() {
for (int i=0; i<CREATION_COUNT; i++) {
repositoryDAO.add(new Repository("repo_" + i, "git", "sync_it", "repo_" + i));
}
assertCreated();
}
private void assertCreated() {
XmlRepositoryDAO assertionDao = new XmlRepositoryDAO(resolver, fileSystem, repositoryExportingCheck);
assertThat(assertionDao.getAll()).hasSize(CREATION_COUNT);
}
@Test
@Timeout(TIMEOUT)
void shouldCreateALotOfRepositoriesInParallel() throws InterruptedException {
ExecutorService executors = Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors());
final XmlRepositoryDAO repositoryDAO = new XmlRepositoryDAO(resolver, fileSystem, repositoryExportingCheck);
for (int i=0; i<CREATION_COUNT; i++) {
executors.submit(create(repositoryDAO, i));
}
executors.shutdown();
executors.awaitTermination(TIMEOUT, TimeUnit.SECONDS);
assertCreated();
}
private Runnable create(XmlRepositoryDAO repositoryDAO, int index) {
return () -> repositoryDAO.add(new Repository("repo_" + index, "git", "sync_it", "repo_" + index));
}
}

View File

@@ -0,0 +1,498 @@
/*
* Copyright (c) 2020 - present Cloudogu GmbH
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3.
*
* 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 Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
package sonia.scm.repository.xml;
import com.google.common.base.Charsets;
import com.google.common.io.Resources;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.ArgumentCaptor;
import org.mockito.Captor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import sonia.scm.io.DefaultFileSystem;
import sonia.scm.io.FileSystem;
import sonia.scm.repository.NamespaceAndName;
import sonia.scm.repository.Repository;
import sonia.scm.repository.RepositoryExportingCheck;
import sonia.scm.repository.RepositoryLocationResolver;
import sonia.scm.repository.RepositoryPermission;
import sonia.scm.repository.xml.PathBasedRepositoryLocationResolver.DownForMaintenanceContext;
import sonia.scm.repository.xml.PathBasedRepositoryLocationResolver.UpAfterMaintenanceContext;
import sonia.scm.store.StoreReadOnlyException;
import java.io.IOException;
import java.net.URL;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Collection;
import java.util.function.BiConsumer;
import java.util.function.Consumer;
import static java.util.Arrays.asList;
import static java.util.Collections.singletonList;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.fail;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith({MockitoExtension.class})
@MockitoSettings(strictness = Strictness.LENIENT)
class XmlRepositoryDAOTest {
private final Repository REPOSITORY = createRepository("42");
@Mock
private PathBasedRepositoryLocationResolver locationResolver;
private Consumer<BiConsumer<String, Path>> triggeredOnForAllLocations = none -> {};
@Mock
private RepositoryExportingCheck repositoryExportingCheck;
private final FileSystem fileSystem = new DefaultFileSystem();
private XmlRepositoryDAO dao;
@BeforeEach
void createDAO(@TempDir Path basePath) {
when(locationResolver.create(Path.class)).thenReturn(
new RepositoryLocationResolver.RepositoryLocationResolverInstance<>() {
@Override
public Path getLocation(String repositoryId) {
return locationResolver.create(repositoryId);
}
@Override
public Path createLocation(String repositoryId) {
return locationResolver.create(repositoryId);
}
@Override
public void setLocation(String repositoryId, Path location) {
}
@Override
public void forAllLocations(BiConsumer<String, Path> consumer) {
triggeredOnForAllLocations.accept(consumer);
}
}
);
when(locationResolver.create(any(Repository.class))).thenAnswer(invocation -> createMockedRepoPath(basePath, invocation.getArgument(0, Repository.class).getId()));
when(locationResolver.create(anyString())).thenAnswer(invocation -> createMockedRepoPath(basePath, invocation.getArgument(0, String.class)));
when(locationResolver.remove(anyString())).thenAnswer(invocation -> basePath.resolve(invocation.getArgument(0).toString()));
}
private static Path createMockedRepoPath(Path basePath, String repositoryId) {
Path resolvedPath = basePath.resolve(repositoryId);
try {
Files.createDirectories(resolvedPath);
} catch (IOException e) {
fail(e);
}
return resolvedPath;
}
@Nested
class WithEmptyDatabase {
@BeforeEach
void createDAO() {
dao = new XmlRepositoryDAO(locationResolver, fileSystem, repositoryExportingCheck);
}
@Test
void shouldReturnXmlType() {
assertThat(dao.getType()).isEqualTo("xml");
}
@Test
void shouldReturnCreationTimeOfLocationResolver() {
long now = 42L;
when(locationResolver.getCreationTime()).thenReturn(now);
assertThat(dao.getCreationTime()).isEqualTo(now);
}
@Test
void shouldReturnLasModifiedOfLocationResolver() {
long now = 42L;
when(locationResolver.getLastModified()).thenReturn(now);
assertThat(dao.getLastModified()).isEqualTo(now);
}
@Test
void shouldReturnTrueForEachContainsMethod() {
dao.add(REPOSITORY);
assertThat(dao.contains(REPOSITORY)).isTrue();
assertThat(dao.contains(REPOSITORY.getId())).isTrue();
assertThat(dao.contains(REPOSITORY.getNamespaceAndName())).isTrue();
}
@Test
void shouldPersistRepository() {
dao.add(REPOSITORY);
String content = getXmlFileContent(REPOSITORY.getId());
assertThat(content).contains("<id>42</id>");
}
@Test
void shouldDeleteDataFile() {
dao.add(REPOSITORY);
dao.delete(REPOSITORY);
assertThat(metadataFile(REPOSITORY.getId())).doesNotExist();
}
@Test
void shouldModifyRepository() {
dao.add(REPOSITORY);
Repository changedRepository = REPOSITORY.clone();
changedRepository.setContact("change");
dao.modify(changedRepository);
String content = getXmlFileContent(REPOSITORY.getId());
assertThat(content).contains("change");
}
@Test
void shouldReturnFalseForEachContainsMethod() {
assertThat(dao.contains(REPOSITORY)).isFalse();
assertThat(dao.contains(REPOSITORY.getId())).isFalse();
assertThat(dao.contains(REPOSITORY.getNamespaceAndName())).isFalse();
}
@Test
void shouldReturnNullForEachGetMethod() {
assertThat(dao.get("42")).isNull();
assertThat(dao.get(new NamespaceAndName("hitchhiker", "HeartOfGold"))).isNull();
}
@Test
void shouldReturnRepository() {
dao.add(REPOSITORY);
assertThat(dao.get("42")).isEqualTo(REPOSITORY);
assertThat(dao.get(new NamespaceAndName("space", "42"))).isEqualTo(REPOSITORY);
}
@Test
void shouldNotReturnTheSameInstance() {
dao.add(REPOSITORY);
Repository repository = dao.get("42");
assertThat(repository).isNotSameAs(REPOSITORY);
}
@Test
void shouldReturnAllRepositories() {
dao.add(REPOSITORY);
Repository secondRepository = createRepository("23");
dao.add(secondRepository);
Collection<Repository> repositories = dao.getAll();
assertThat(repositories)
.containsExactlyInAnyOrder(REPOSITORY, secondRepository);
}
@Test
void shouldModifyRepositoryTwice() {
REPOSITORY.setDescription("HeartOfGold");
dao.add(REPOSITORY);
assertThat(dao.get("42").getDescription()).isEqualTo("HeartOfGold");
Repository heartOfGold = createRepository("42");
heartOfGold.setDescription("Heart of Gold");
dao.modify(heartOfGold);
assertThat(dao.get("42").getDescription()).isEqualTo("Heart of Gold");
}
@Test
void shouldNotModifyArchivedRepository() {
REPOSITORY.setArchived(true);
dao.add(REPOSITORY);
Repository heartOfGold = createRepository("42");
heartOfGold.setArchived(true);
assertThrows(StoreReadOnlyException.class, () -> dao.modify(heartOfGold));
}
@Test
void shouldNotModifyExportingRepository() {
when(repositoryExportingCheck.isExporting(REPOSITORY)).thenReturn(true);
dao.add(REPOSITORY);
Repository heartOfGold = createRepository("42");
assertThrows(StoreReadOnlyException.class, () -> dao.modify(heartOfGold));
}
@Test
void shouldRemoveRepository() {
dao.add(REPOSITORY);
assertThat(dao.contains("42")).isTrue();
dao.delete(REPOSITORY);
assertThat(dao.contains("42")).isFalse();
assertThat(dao.contains(REPOSITORY.getNamespaceAndName())).isFalse();
Path storePath = metadataFile(REPOSITORY.getId());
assertThat(storePath).doesNotExist();
}
@Test
void shouldNotRemoveArchivedRepository() {
REPOSITORY.setArchived(true);
dao.add(REPOSITORY);
assertThat(dao.contains("42")).isTrue();
assertThrows(StoreReadOnlyException.class, () -> dao.delete(REPOSITORY));
}
@Test
void shouldNotRemoveExportingRepository() {
when(repositoryExportingCheck.isExporting(REPOSITORY)).thenReturn(true);
dao.add(REPOSITORY);
assertThat(dao.contains("42")).isTrue();
assertThrows(StoreReadOnlyException.class, () -> dao.delete(REPOSITORY));
}
@Test
void shouldRenameTheRepository() {
dao.add(REPOSITORY);
REPOSITORY.setNamespace("hg2tg");
REPOSITORY.setName("hog");
dao.modify(REPOSITORY);
Repository repository = dao.get("42");
assertThat(repository.getNamespace()).isEqualTo("hg2tg");
assertThat(repository.getName()).isEqualTo("hog");
assertThat(dao.contains(new NamespaceAndName("hg2tg", "hog"))).isTrue();
assertThat(dao.contains(new NamespaceAndName("hitchhiker", "HeartOfGold"))).isFalse();
String content = getXmlFileContent(REPOSITORY.getId());
assertThat(content).contains("<name>hog</name>");
}
@Test
void shouldDeleteRepositoryEvenWithChangedNamespace() {
dao.add(REPOSITORY);
REPOSITORY.setNamespace("hg2tg");
REPOSITORY.setName("hog");
dao.delete(REPOSITORY);
assertThat(dao.contains(new NamespaceAndName("space", "42"))).isFalse();
}
@Test
void shouldRemoveRepositoryDirectoryAfterDeletion() {
dao.add(REPOSITORY);
Path path = locationResolver.create(REPOSITORY.getId());
assertThat(path).isDirectory();
dao.delete(REPOSITORY);
assertThat(path).doesNotExist();
}
@Test
void shouldPersistPermissions() {
REPOSITORY.setPermissions(asList(new RepositoryPermission("trillian", asList("read", "write"), false), new RepositoryPermission("vogons", singletonList("delete"), true)));
dao.add(REPOSITORY);
String content = getXmlFileContent(REPOSITORY.getId());
assertThat(content)
.containsSubsequence("trillian", "<verb>read</verb>", "<verb>write</verb>")
.containsSubsequence("vogons", "<verb>delete</verb>");
}
@Test
void shouldUpdateRepositoryPathDatabse() {
dao.add(REPOSITORY);
verify(locationResolver, never()).updateModificationDate();
dao.modify(REPOSITORY);
verify(locationResolver).updateModificationDate();
}
@Test
void shouldGetAllWithCorrectSorting() {
dao.add(createRepository("banana1", "banana", "red"));
dao.add(createRepository("banana2", "banana.venezuela", "red"));
Collection<Repository> repositories = dao.getAll();
assertThat(repositories)
.hasSize(2)
.extracting("id").containsExactly("banana1", "banana2");
}
private String getXmlFileContent(String id) {
Path storePath = metadataFile(id);
assertThat(storePath).isRegularFile();
return content(storePath);
}
private Path metadataFile(String id) {
return locationResolver.create(id).resolve("metadata.xml");
}
private String content(Path storePath) {
try {
return Files.readString(storePath, Charsets.UTF_8);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}
@Nested
class WithExistingRepositories {
private Path repositoryPath;
@Captor
private ArgumentCaptor<PathBasedRepositoryLocationResolver.MaintenanceCallback> callbackArgumentCaptor;
@BeforeEach
void createMetadataFileForRepository(@TempDir Path basePath) throws IOException {
repositoryPath = basePath.resolve("existing");
prepareRepositoryPath(repositoryPath);
}
@Test
void shouldReadExistingRepositoriesFromPathDatabase() {
// given
mockExistingPath();
// when
XmlRepositoryDAO dao = new XmlRepositoryDAO(locationResolver, fileSystem, repositoryExportingCheck);
// then
assertThat(dao.contains(new NamespaceAndName("space", "existing"))).isTrue();
}
@Test
void shouldRefreshWithExistingRepositoriesFromPathDatabase() {
// given
mockExistingPath();
XmlRepositoryDAO dao = new XmlRepositoryDAO(locationResolver, fileSystem, repositoryExportingCheck);
// when
dao.refresh();
// then
verify(locationResolver).refresh();
assertThat(dao.contains(new NamespaceAndName("space", "existing"))).isTrue();
}
@Test
void shouldHandleMaintenanceEvents() {
doNothing().when(locationResolver).registerMaintenanceCallback(callbackArgumentCaptor.capture());
mockExistingPath();
XmlRepositoryDAO dao = new XmlRepositoryDAO(locationResolver, fileSystem, repositoryExportingCheck);
callbackArgumentCaptor.getValue().downForMaintenance(new DownForMaintenanceContext("existing"));
assertThat(dao.contains("existing")).isFalse();
assertThat(dao.contains(new NamespaceAndName("space", "existing"))).isFalse();
assertThat(dao.getAll()).isEmpty();
callbackArgumentCaptor.getValue().upAfterMaintenance(new UpAfterMaintenanceContext("existing", repositoryPath));
assertThat(dao.contains("existing")).isTrue();
assertThat(dao.contains(new NamespaceAndName("space", "existing"))).isTrue();
assertThat(dao.getAll()).hasSize(1);
}
private void mockExistingPath() {
triggeredOnForAllLocations = consumer -> consumer.accept("existing", repositoryPath);
}
}
@Nested
class WithDuplicateRepositories {
private Path repositoryPath;
private Path duplicateRepositoryPath;
@BeforeEach
void createMetadataFileForRepository(@TempDir Path basePath) throws IOException {
repositoryPath = basePath.resolve("existing");
duplicateRepositoryPath = basePath.resolve("duplicate");
prepareRepositoryPath(repositoryPath);
prepareRepositoryPath(duplicateRepositoryPath);
}
@Test
void shouldRenameDuplicateRepositories() {
mockExistingPath();
XmlRepositoryDAO dao = new XmlRepositoryDAO(locationResolver, fileSystem, repositoryExportingCheck);
assertThat(dao.contains(new NamespaceAndName("space", "existing"))).isTrue();
assertThat(dao.contains(new NamespaceAndName("space", "existing-existing2-DUPLICATE"))).isTrue();
}
private void mockExistingPath() {
triggeredOnForAllLocations = consumer -> {
consumer.accept("existing", repositoryPath);
consumer.accept("existing2", duplicateRepositoryPath);
};
}
}
private void prepareRepositoryPath(Path repositoryPath) throws IOException {
Files.createDirectories(repositoryPath);
URL metadataUrl = Resources.getResource("sonia/scm/store/repositoryDaoMetadata.xml");
Files.copy(metadataUrl.openStream(), repositoryPath.resolve("metadata.xml"));
}
private Repository createRepository(String id, String namespace, String name) {
return new Repository(id, "xml", namespace, name);
}
private Repository createRepository(String id) {
return createRepository(id, "space", id);
}
}

View File

@@ -0,0 +1,113 @@
/*
* Copyright (c) 2020 - present Cloudogu GmbH
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3.
*
* 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 Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
package sonia.scm.store;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import sonia.scm.SCMContextProvider;
import sonia.scm.repository.RepositoryLocationResolver;
import sonia.scm.update.StoreUpdateStepUtilFactory;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.lenient;
@ExtendWith(MockitoExtension.class)
class FileStoreUpdateStepUtilFactoryTest {
@Mock
private RepositoryLocationResolver locationResolver;
@Mock
private RepositoryLocationResolver.RepositoryLocationResolverInstance locationResolverInstance;
@Mock
private SCMContextProvider contextProvider;
@InjectMocks
private FileStoreUpdateStepUtilFactory factory;
private Path globalPath;
private Path repositoryPath;
@BeforeEach
void initPaths(@TempDir Path temp) throws IOException {
globalPath = temp.resolve("global");
Files.createDirectories(globalPath);
lenient().doReturn(globalPath.toFile()).when(contextProvider).getBaseDirectory();
repositoryPath = temp.resolve("repo");
Files.createDirectories(repositoryPath);
lenient().doReturn(true).when(locationResolver).supportsLocationType(Path.class);
lenient().doReturn(locationResolverInstance).when(locationResolver).forClass(Path.class);
lenient().doReturn(repositoryPath).when(locationResolverInstance).getLocation("repo-id");
}
@Test
void shouldMoveGlobalDataDirectory() throws IOException {
Path dataPath = globalPath.resolve("var").resolve("data");
Files.createDirectories(dataPath.resolve("something"));
Files.createFile(dataPath.resolve("something").resolve("some.file"));
StoreUpdateStepUtilFactory.StoreUpdateStepUtil util =
factory
.forType(StoreType.DATA)
.forName("something")
.build();
util.renameStore("new-name");
assertThat(dataPath.resolve("new-name").resolve("some.file")).exists();
assertThat(dataPath.resolve("something")).doesNotExist();
}
@Test
void shouldMoveRepositoryDataDirectory() throws IOException {
Path dataPath = repositoryPath.resolve("store").resolve("data");
Files.createDirectories(dataPath.resolve("something"));
Files.createFile(dataPath.resolve("something").resolve("some.file"));
StoreUpdateStepUtilFactory.StoreUpdateStepUtil util =
factory
.forType(StoreType.DATA)
.forName("something")
.forRepository("repo-id")
.build();
util.renameStore("new-name");
assertThat(dataPath.resolve("new-name").resolve("some.file")).exists();
assertThat(dataPath.resolve("something")).doesNotExist();
}
@Test
void shouldHandleMissingMoveGlobalDataDirectory() throws IOException {
StoreUpdateStepUtilFactory.StoreUpdateStepUtil util =
factory
.forType(StoreType.DATA)
.forName("something")
.build();
util.renameStore("new-name");
assertThat(globalPath.resolve("new-name")).doesNotExist();
}
}

View File

@@ -0,0 +1,46 @@
/*
* Copyright (c) 2020 - present Cloudogu GmbH
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3.
*
* 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 Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
package sonia.scm.store;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Answers;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import sonia.scm.repository.Repository;
import sonia.scm.repository.RepositoryLocationResolver;
import sonia.scm.repository.RepositoryTestData;
import static org.assertj.core.api.Assertions.assertThat;
@ExtendWith(MockitoExtension.class)
class RepositoryStoreImporterTest {
private static final Repository REPOSITORY = RepositoryTestData.createHeartOfGold();
@Mock(answer = Answers.RETURNS_DEEP_STUBS)
private RepositoryLocationResolver locationResolver;
@InjectMocks
private RepositoryStoreImporter repositoryStoreImporter;
@Test
void shouldImportStore() {
StoreEntryImporterFactory storeEntryImporterFactory = repositoryStoreImporter.doImport(REPOSITORY);
assertThat(storeEntryImporterFactory).isInstanceOf(StoreEntryImporterFactory.class);
}
}

View File

@@ -0,0 +1,161 @@
/*
* Copyright (c) 2020 - present Cloudogu GmbH
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3.
*
* 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 Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
package sonia.scm.store.file;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import sonia.scm.store.StoreException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static sonia.scm.CopyOnWrite.withTemporaryFile;
class CopyOnWriteTest {
@Test
void shouldCreateNewFile(@TempDir Path tempDir) {
Path expectedFile = tempDir.resolve("toBeCreated.txt");
withTemporaryFile(file -> {
try (OutputStream os = new FileOutputStream(file.toFile())) {
os.write("great success".getBytes());
}
}, expectedFile);
assertThat(expectedFile).hasContent("great success");
}
@Test
void shouldOverwriteExistingFile(@TempDir Path tempDir) throws IOException {
Path expectedFile = tempDir.resolve("toBeOverwritten.txt");
Files.createFile(expectedFile);
withTemporaryFile(file -> {
try (OutputStream os = new FileOutputStream(file.toFile())) {
os.write("great success".getBytes());
}
}, expectedFile);
assertThat(expectedFile).hasContent("great success");
}
@Test
void shouldFailForDirectory(@TempDir Path tempDir) {
assertThrows(IllegalArgumentException.class, () -> withTemporaryFile(file -> {
try (OutputStream os = new FileOutputStream(file.toFile())) {
os.write("should not be written".getBytes());
}
}, tempDir));
}
@Test
void shouldFailForMissingDirectory() {
assertThrows(IllegalArgumentException.class, () -> withTemporaryFile(file -> {
try (OutputStream os = new FileOutputStream(file.toFile())) {
os.write("should not be written".getBytes());
}
}, Paths.get("someFile")));
}
@Test
void shouldKeepBackupIfTemporaryFileCouldNotBeWritten(@TempDir Path tempDir) throws IOException {
Path unchangedOriginalFile = tempDir.resolve("notToBeDeleted.txt");
try (OutputStream unchangedOriginalOs = new FileOutputStream(unchangedOriginalFile.toFile())) {
unchangedOriginalOs.write("this should be kept".getBytes());
}
assertThrows(
StoreException.class,
() -> withTemporaryFile(
file -> {
throw new IOException("test");
},
unchangedOriginalFile));
assertThat(unchangedOriginalFile).hasContent("this should be kept");
}
@Test
void shouldDeleteTemporaryFileIfFileCouldNotBeWritten(@TempDir Path tempDir) throws IOException {
Path unchangedOriginalFile = tempDir.resolve("target.txt");
assertThrows(
StoreException.class,
() -> withTemporaryFile(
file -> {
throw new IOException("test");
},
unchangedOriginalFile));
assertThat(tempDir).isEmptyDirectory();
}
@Test
void shouldNotWrapRuntimeExceptions(@TempDir Path tempDir) throws IOException {
Path someFile = tempDir.resolve("something.txt");
assertThrows(
NullPointerException.class,
() -> withTemporaryFile(
file -> {
throw new NullPointerException("test");
},
someFile));
}
@Test
void shouldKeepBackupIfTemporaryFileIsMissing(@TempDir Path tempDir) throws IOException {
Path backedUpFile = tempDir.resolve("notToBeDeleted.txt");
try (OutputStream backedUpOs = new FileOutputStream(backedUpFile.toFile())) {
backedUpOs.write("this should be kept".getBytes());
}
assertThrows(
StoreException.class,
() -> withTemporaryFile(
Files::delete,
backedUpFile));
assertThat(backedUpFile).hasContent("this should be kept");
}
@Test
void shouldDeleteExistingFile(@TempDir Path tempDir) throws IOException {
Path expectedFile = tempDir.resolve("toBeReplaced.txt");
try (OutputStream expectedOs = new FileOutputStream(expectedFile.toFile())) {
expectedOs.write("this should be removed".getBytes());
}
withTemporaryFile(file -> {
try (OutputStream os = new FileOutputStream(file.toFile())) {
os.write("overwritten".getBytes()) ;
}
}, expectedFile);
assertThat(Files.list(tempDir)).hasSize(1);
}
}

View File

@@ -0,0 +1,130 @@
/*
* Copyright (c) 2020 - present Cloudogu GmbH
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3.
*
* 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 Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
package sonia.scm.store.file;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.junit.jupiter.MockitoExtension;
import sonia.scm.cache.MapCache;
import java.io.File;
import static org.assertj.core.api.Assertions.assertThat;
@ExtendWith(MockitoExtension.class)
class DataFileCacheTest {
@Nested
class WithActivatedCache {
private final MapCache<File, Object> backingCache = new MapCache<>();
private final DataFileCache dataFileCache = new DataFileCache(backingCache, true);
@Test
void shouldReturnCachedData() {
File file = new File("/some.string");
backingCache.put(file, "some string");
DataFileCache.DataFileCacheInstance instance = dataFileCache.instanceFor(String.class);
Object result = instance.get(file, () -> {
throw new RuntimeException("should not be read");
});
assertThat(result).isSameAs("some string");
}
@Test
void shouldReadDataIfNotCached() {
File file = new File("/some.string");
DataFileCache.DataFileCacheInstance instance = dataFileCache.instanceFor(String.class);
Object result = instance.get(file, () -> "some string");
assertThat(result).isSameAs("some string");
assertThat(backingCache.get(file)).isSameAs("some string");
}
@Test
void shouldReadDataAnewIfOfDifferentType() {
File file = new File("/some.string");
backingCache.put(file, 42);
DataFileCache.DataFileCacheInstance instance = dataFileCache.instanceFor(String.class);
Object result = instance.get(file, () -> "some string");
assertThat(result).isSameAs("some string");
assertThat(backingCache.get(file)).isSameAs("some string");
}
@Test
void shouldRemoveOutdatedDataIfOfDifferentType() {
File file = new File("/some.string");
backingCache.put(file, 42);
DataFileCache.DataFileCacheInstance instance = dataFileCache.instanceFor(String.class);
Object result = instance.get(file, () -> null);
assertThat(result).isNull();
assertThat(backingCache.get(file)).isNull();
}
@Test
void shouldCacheNewData() {
File file = new File("/some.string");
DataFileCache.DataFileCacheInstance instance = dataFileCache.instanceFor(String.class);
instance.put(file, "some string");
assertThat(backingCache.get(file)).isSameAs("some string");
}
@Test
void shouldRemoveDataFromCache() {
File file = new File("/some.string");
backingCache.put(file, "some string");
DataFileCache.DataFileCacheInstance instance = dataFileCache.instanceFor(String.class);
instance.remove(file);
assertThat(backingCache.get(file)).isNull();
}
}
@Nested
class WithDeactivatedCache {
private final DataFileCache dataFileCache = new DataFileCache(null, false);
@Test
void shouldReadData() {
File file = new File("/some.string");
DataFileCache.DataFileCacheInstance instance = dataFileCache.instanceFor(String.class);
Object result = instance.get(file, () -> "some string");
assertThat(result).isSameAs("some string");
}
}
}

View File

@@ -0,0 +1,62 @@
/*
* Copyright (c) 2020 - present Cloudogu GmbH
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3.
*
* 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 Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
package sonia.scm.store.file;
import org.junit.jupiter.api.Test;
import java.nio.file.Path;
import java.nio.file.Paths;
import static org.assertj.core.api.Assertions.assertThat;
class ExportableBlobFileStoreTest {
@Test
void shouldIgnoreStoreIfExcludedStore() {
Path dir = Paths.get("test/path/repository-export");
ExportableBlobFileStore exportableBlobFileStore = new ExportableBlobFileStore(dir);
Path file = Paths.get(dir.toString(), "some.blob");
boolean result = exportableBlobFileStore.shouldIncludeFile(file);
assertThat(result).isFalse();
}
@Test
void shouldIgnoreStoreIfNotBlob() {
Path dir = Paths.get("test/path/any-store");
ExportableBlobFileStore exportableBlobFileStore = new ExportableBlobFileStore(dir);
Path file = Paths.get(dir.toString(), "some.unblob");
boolean result = exportableBlobFileStore.shouldIncludeFile(file);
assertThat(result).isFalse();
}
@Test
void shouldIncludeStore() {
Path dir = Paths.get("test/path/any-blob-store");
ExportableBlobFileStore exportableBlobFileStore = new ExportableBlobFileStore(dir);
Path file = Paths.get(dir.toString(), "some.blob");
boolean result = exportableBlobFileStore.shouldIncludeFile(file);
assertThat(result).isTrue();
}
}

View File

@@ -0,0 +1,128 @@
/*
* Copyright (c) 2020 - present Cloudogu GmbH
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3.
*
* 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 Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
package sonia.scm.store.file;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import sonia.scm.store.ExportableStore;
import sonia.scm.store.Exporter;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileWriter;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class ExportableFileStoreTest {
@Mock
Exporter exporter;
@Test
void shouldNotPutContentIfNoFilesExists(@TempDir Path temp) throws IOException {
Path dataStoreDir = temp.resolve("some-store");
Files.createDirectories(dataStoreDir);
ExportableStore exportableFileStore = new ExportableDataFileStore(dataStoreDir);
exportableFileStore.export(exporter);
verify(exporter, never()).put(anyString(), anyLong());
}
@Test
void shouldPutContentIntoExporterForDataStore(@TempDir Path temp) throws IOException {
createFile(temp, "data", "trace", "first.xml");
createFile(temp, "data", "trace", "second.xml");
ByteArrayOutputStream os = new ByteArrayOutputStream();
ExportableStore exportableFileStore = new ExportableDataFileStore(temp.resolve("data").resolve("trace"));
when(exporter.put(anyString(), anyLong())).thenReturn(os);
exportableFileStore.export(exporter);
verify(exporter).put(eq("first.xml"), anyLong());
verify(exporter).put(eq("second.xml"), anyLong());
assertThat(os.toString()).isNotBlank();
}
@Test
void shouldPutContentIntoExporterForConfigStore(@TempDir Path temp) throws IOException {
createFile(temp, "config", "", "first.xml");
ByteArrayOutputStream os = new ByteArrayOutputStream();
ExportableStore exportableConfigFileStore = new ExportableConfigFileStore(temp.resolve("config").resolve("first.xml"));
when(exporter.put(anyString(), anyLong())).thenReturn(os);
exportableConfigFileStore.export(exporter);
verify(exporter).put(eq("first.xml"), anyLong());
assertThat(os.toString()).isNotBlank();
}
@Test
void shouldPutContentIntoExporterForBlobStore(@TempDir Path temp) throws IOException {
createFile(temp, "blob", "assets", "first.blob");
ByteArrayOutputStream os = new ByteArrayOutputStream();
Exporter exporter = mock(Exporter.class);
ExportableStore exportableBlobFileStore = new ExportableBlobFileStore(temp.resolve("blob").resolve("assets"));
when(exporter.put(anyString(), anyLong())).thenReturn(os);
exportableBlobFileStore.export(exporter);
verify(exporter).put(eq("first.blob"), anyLong());
assertThat(os.toString()).isNotBlank();
}
@Test
void shouldSkipFilteredBlobFiles(@TempDir Path temp) throws IOException {
createFile(temp, "blob", "security", "second.xml");
ByteArrayOutputStream os = new ByteArrayOutputStream();
Exporter exporter = mock(Exporter.class);
ExportableStore exportableBlobFileStore = new ExportableBlobFileStore(temp.resolve("blob").resolve("security"));
exportableBlobFileStore.export(exporter);
verify(exporter, never()).put(anyString(), anyLong());
assertThat(os.toString()).isBlank();
}
private File createFile(Path temp, String type, String name, String fileName) throws IOException {
Path path = name != null ? temp.resolve(type).resolve(name) : temp.resolve(type);
new File(path.toUri()).mkdirs();
File file = new File(path.toFile(), fileName);
if (!file.exists()) {
file.createNewFile();
}
FileWriter source = new FileWriter(file);
source.write("something");
source.close();
return file;
}
}

View File

@@ -0,0 +1,45 @@
/*
* Copyright (c) 2020 - present Cloudogu GmbH
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3.
*
* 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 Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
package sonia.scm.store.file;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import sonia.scm.store.StoreEntryMetaData;
import sonia.scm.store.StoreType;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class FileBasedStoreEntryImporterFactoryTest {
@Test
void shouldCreateStoreEntryImporterForDataStore(@TempDir Path temp) {
FileBasedStoreEntryImporterFactory factory = new FileBasedStoreEntryImporterFactory(temp);
FileBasedStoreEntryImporter dataImporter = (FileBasedStoreEntryImporter) factory.importStore(new StoreEntryMetaData(StoreType.DATA, "hitchhiker"));
assertThat(dataImporter.getDirectory()).isEqualTo(temp.resolve("store").resolve("data").resolve("hitchhiker"));
}
@Test
void shouldCreateStoreEntryImporterForConfigStore(@TempDir Path temp) {
FileBasedStoreEntryImporterFactory factory = new FileBasedStoreEntryImporterFactory(temp);
FileBasedStoreEntryImporter configImporter = (FileBasedStoreEntryImporter) factory.importStore(new StoreEntryMetaData(StoreType.CONFIG, ""));
assertThat(configImporter.getDirectory()).isEqualTo(temp.resolve("store").resolve("config"));
}
}

View File

@@ -0,0 +1,40 @@
/*
* Copyright (c) 2020 - present Cloudogu GmbH
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3.
*
* 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 Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
package sonia.scm.store.file;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.ByteArrayInputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class FileBasedStoreEntryImporterTest {
@Test
void shouldCreateFileFromInputStream(@TempDir Path temp) {
FileBasedStoreEntryImporter importer = new FileBasedStoreEntryImporter(temp);
String fileName = "testStore.xml";
importer.importEntry(fileName, new ByteArrayInputStream("testdata".getBytes()));
assertThat(Files.exists(temp.resolve(fileName))).isTrue();
assertThat(temp.resolve(fileName)).hasContent("testdata");
}
}

View File

@@ -0,0 +1,229 @@
/*
* Copyright (c) 2020 - present Cloudogu GmbH
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3.
*
* 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 Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
package sonia.scm.store.file;
import com.google.common.io.ByteStreams;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import sonia.scm.AbstractTestBase;
import sonia.scm.repository.Repository;
import sonia.scm.repository.RepositoryReadOnlyChecker;
import sonia.scm.repository.RepositoryTestData;
import sonia.scm.security.UUIDKeyGenerator;
import sonia.scm.store.Blob;
import sonia.scm.store.BlobStore;
import sonia.scm.store.BlobStoreFactory;
import sonia.scm.store.EntryAlreadyExistsStoreException;
import sonia.scm.store.StoreReadOnlyException;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.List;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class FileBlobStoreTest extends AbstractTestBase
{
private final Repository repository = RepositoryTestData.createHeartOfGold();
private final RepositoryReadOnlyChecker readOnlyChecker = mock(RepositoryReadOnlyChecker.class);
private BlobStore store;
@BeforeEach
void createBlobStore()
{
store = createBlobStoreFactory()
.withName("test")
.forRepository(repository)
.build();
}
@Test
void testClear()
{
store.create("1");
store.create("2");
store.create("3");
assertNotNull(store.get("1"));
assertNotNull(store.get("2"));
assertNotNull(store.get("3"));
store.clear();
assertNull(store.get("1"));
assertNull(store.get("2"));
assertNull(store.get("3"));
}
@Test
void testContent() throws IOException
{
Blob blob = store.create();
write(blob, "Hello");
assertEquals("Hello", read(blob));
blob = store.get(blob.getId());
assertEquals("Hello", read(blob));
write(blob, "Other Text");
assertEquals("Other Text", read(blob));
blob = store.get(blob.getId());
assertEquals("Other Text", read(blob));
}
@Test
void testCreateAlreadyExistingEntry()
{
assertNotNull(store.create("1"));
assertThrows(EntryAlreadyExistsStoreException.class, () -> store.create("1"));
}
@Test
void testCreateWithId()
{
Blob blob = store.create("1");
assertNotNull(blob);
blob = store.get("1");
assertNotNull(blob);
}
@Test
void testCreateWithoutId()
{
Blob blob = store.create();
assertNotNull(blob);
String id = blob.getId();
assertNotNull(id);
blob = store.get(id);
assertNotNull(blob);
}
@Test
void testGet()
{
Blob blob = store.get("1");
assertNull(blob);
blob = store.create("1");
assertNotNull(blob);
blob = store.get("1");
assertNotNull(blob);
}
@Test
void testGetAll()
{
store.create("1");
store.create("2");
store.create("3");
List<Blob> all = store.getAll();
assertNotNull(all);
assertFalse(all.isEmpty());
assertEquals(3, all.size());
boolean c1 = false;
boolean c2 = false;
boolean c3 = false;
for (Blob b : all)
{
if ("1".equals(b.getId()))
{
c1 = true;
}
else if ("2".equals(b.getId()))
{
c2 = true;
}
else if ("3".equals(b.getId()))
{
c3 = true;
}
}
assertTrue(c1);
assertTrue(c2);
assertTrue(c3);
}
@Nested
class WithArchivedRepository {
@BeforeEach
void setRepositoryArchived() {
store.create("1"); // store for test must not be empty
when(readOnlyChecker.isReadOnly(repository.getId())).thenReturn(true);
createBlobStore();
}
@Test
void shouldNotClear() {
assertThrows(StoreReadOnlyException.class, () -> store.clear());
}
@Test
void shouldNotRemove() {
assertThrows(StoreReadOnlyException.class, () -> store.remove("1"));
}
}
private String read(Blob blob) throws IOException
{
InputStream input = blob.getInputStream();
byte[] bytes = ByteStreams.toByteArray(input);
input.close();
return new String(bytes);
}
private void write(Blob blob, String content) throws IOException
{
OutputStream output = blob.getOutputStream();
output.write(content.getBytes());
output.close();
blob.commit();
}
protected BlobStoreFactory createBlobStoreFactory()
{
return new FileBlobStoreFactory(contextProvider, repositoryLocationResolver, new UUIDKeyGenerator(), readOnlyChecker);
}
}

View File

@@ -0,0 +1,58 @@
/*
* Copyright (c) 2020 - present Cloudogu GmbH
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3.
*
* 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 Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
package sonia.scm.store.file;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.junit.jupiter.MockitoExtension;
import sonia.scm.TempDirRepositoryLocationResolver;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collection;
import static java.util.Arrays.asList;
import static org.assertj.core.api.Assertions.assertThat;
@ExtendWith(MockitoExtension.class)
class FileNamespaceUpdateIteratorTest {
private TempDirRepositoryLocationResolver locationResolver;
@BeforeEach
void initLocationResolver(@TempDir Path tempDir) throws IOException {
locationResolver = new TempDirRepositoryLocationResolver(tempDir.toFile());
Files.write(tempDir.resolve("metadata.xml"), asList(
"<repositories>",
" <namespace>hitchhike</namespace>",
"</repositories>"
));
}
@Test
void shouldFindNamespaces() {
Collection<String> foundNamespaces = new ArrayList<>();
new FileNamespaceUpdateIterator(locationResolver)
.forEachNamespace(foundNamespaces::add);
assertThat(foundNamespaces).containsExactly("hitchhike");
}
}

View File

@@ -0,0 +1,124 @@
/*
* Copyright (c) 2020 - present Cloudogu GmbH
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3.
*
* 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 Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
package sonia.scm.store.file;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.Answers;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import sonia.scm.repository.Repository;
import sonia.scm.repository.RepositoryLocationResolver;
import sonia.scm.repository.RepositoryTestData;
import sonia.scm.store.ExportableStore;
import sonia.scm.store.StoreType;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Arrays;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class FileStoreExporterTest {
private static final Repository REPOSITORY = RepositoryTestData.create42Puzzle();
@Mock(answer = Answers.RETURNS_DEEP_STUBS)
private RepositoryLocationResolver resolver;
@InjectMocks
private FileStoreExporter fileStoreExporter;
private Path storePath;
@BeforeEach
void setUpStorePath(@TempDir Path temp) {
storePath = temp.resolve("store");
when(resolver.forClass(Path.class).getLocation(REPOSITORY.getId())).thenReturn(temp);
}
@Test
void shouldReturnEmptyList(@TempDir Path temp) {
when(resolver.forClass(Path.class).getLocation(REPOSITORY.getId())).thenReturn(temp);
List<ExportableStore> exportableStores = fileStoreExporter.listExportableStores(REPOSITORY);
assertThat(exportableStores).isEmpty();
}
@Test
void shouldReturnConfigStores() throws IOException {
createFile(StoreType.CONFIG.getValue(), "config.xml")
.withContent("<?xml version=\"1.0\" ?>", "<data>", "some arbitrary content", "</data>");
List<ExportableStore> exportableStores = fileStoreExporter.listExportableStores(REPOSITORY);
assertThat(exportableStores).hasSize(1);
assertThat(exportableStores.stream().filter(e -> e.getMetaData().getType().equals(StoreType.CONFIG))).hasSize(1);
}
@Test
void shouldReturnConfigEntryStores() throws IOException {
createFile(StoreType.CONFIG.getValue(), "config-entry.xml")
.withContent("<?xml version=\"1.0\" ?>", "<configuration type=\"config-entry\">", "</configuration>");
List<ExportableStore> exportableStores = fileStoreExporter.listExportableStores(REPOSITORY);
assertThat(exportableStores).hasSize(1);
assertThat(exportableStores.stream().filter(e -> e.getMetaData().getType().equals(StoreType.CONFIG_ENTRY))).hasSize(1);
}
@Test
void shouldReturnDataStores() throws IOException {
createFile(StoreType.DATA.getValue(), "ci", "data.xml");
createFile(StoreType.DATA.getValue(), "jenkins", "data.xml");
List<ExportableStore> exportableStores = fileStoreExporter.listExportableStores(REPOSITORY);
assertThat(exportableStores).hasSize(2);
assertThat(exportableStores.stream().filter(e -> e.getMetaData().getType().equals(StoreType.DATA))).hasSize(2);
}
private FileWriter createFile(String... names) throws IOException {
Path file = Arrays.stream(names).map(Paths::get).reduce(Path::resolve).map(storePath::resolve).orElse(storePath);
Files.createDirectories(file.getParent());
if (!Files.exists(file)) {
Files.createFile(file);
}
return new FileWriter(file);
}
private static class FileWriter {
private final Path file;
private FileWriter(Path file) {
this.file = file;
}
void withContent(String... content) throws IOException {
Files.write(file, Arrays.asList(content));
}
}
}

View File

@@ -0,0 +1,185 @@
/*
* Copyright (c) 2020 - present Cloudogu GmbH
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3.
*
* 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 Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
package sonia.scm.store.file;
import com.google.common.io.Closeables;
import com.google.common.io.Resources;
import org.junit.Test;
import sonia.scm.security.AssignedPermission;
import sonia.scm.security.UUIDKeyGenerator;
import sonia.scm.store.ConfigurationEntryStore;
import sonia.scm.store.ConfigurationEntryStoreFactory;
import sonia.scm.store.ConfigurationEntryStoreTestBase;
import sonia.scm.store.StoreObject;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.net.URL;
import java.util.UUID;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
public class JAXBConfigurationEntryStoreTest
extends ConfigurationEntryStoreTestBase
{
private static final String RESOURCE_FIXED =
"sonia/scm/store/fixed.format.xml";
private static final String RESOURCE_WRONG =
"sonia/scm/store/wrong.format.xml";
@Test
public void testLoad() throws IOException
{
ConfigurationEntryStore<AssignedPermission> store =
createPermissionStore(RESOURCE_FIXED);
AssignedPermission a1 = store.get("3ZOHKUePB3");
assertEquals("tuser", a1.getName());
AssignedPermission a2 = store.get("7COHL2j1G1");
assertEquals("tuser2", a2.getName());
AssignedPermission a3 = store.get("A0OHL3Qqw2");
assertEquals("tuser3", a3.getName());
}
@Test
public void testLoadWrongFormat() throws IOException
{
ConfigurationEntryStore<AssignedPermission> store =
createPermissionStore(RESOURCE_WRONG);
AssignedPermission a1 = store.get("3ZOHKUePB3");
assertEquals("tuser", a1.getName());
AssignedPermission a2 = store.get("7COHL2j1G1");
assertEquals("tuser2", a2.getName());
AssignedPermission a3 = store.get("A0OHL3Qqw2");
assertEquals("tuser3", a3.getName());
}
/**
* Method description
*
*
* @throws IOException
*/
@Test
public void testStoreAndLoad() throws IOException
{
String name = UUID.randomUUID().toString();
ConfigurationEntryStore<AssignedPermission> store = createPermissionStore(RESOURCE_FIXED, name);
store.put("a45", new AssignedPermission("tuser4", "repository:create"));
store = createConfigurationStoreFactory()
.withType(AssignedPermission.class)
.withName(name)
.build();
AssignedPermission ap = store.get("a45");
assertNotNull(ap);
assertEquals("tuser4", ap.getName());
assertEquals("repository:create", ap.getPermission().getValue());
}
@Test
public void shouldStoreAndLoadInRepository() throws IOException
{
repoStore.put("abc", new StoreObject("abc_value"));
StoreObject storeObject = repoStore.get("abc");
assertNotNull(storeObject);
assertEquals("abc_value", storeObject.getValue());
}
@Override
protected ConfigurationEntryStoreFactory createConfigurationStoreFactory()
{
return new JAXBConfigurationEntryStoreFactory(contextProvider, repositoryLocationResolver, new UUIDKeyGenerator(), null, new StoreCacheFactory(new StoreCacheConfigProvider(false)));
}
private void copy(String resource, String name) throws IOException
{
URL url = Resources.getResource(resource);
File confdir = new File(contextProvider.getBaseDirectory(), "config");
File file = new File(confdir, name.concat(".xml"));
OutputStream output = null;
try
{
output = new FileOutputStream(file);
Resources.copy(url, output);
}
finally
{
Closeables.close(output, true);
}
}
private ConfigurationEntryStore<AssignedPermission> createPermissionStore(
String resource)
throws IOException
{
return createPermissionStore(resource, null);
}
/**
* Method description
*
*
* @param resource
* @param name
*
* @return
*
* @throws IOException
*/
private ConfigurationEntryStore<AssignedPermission> createPermissionStore(
String resource, String name)
throws IOException
{
if (name == null)
{
name = UUID.randomUUID().toString();
}
copy(resource, name);
return createConfigurationStoreFactory()
.withType(AssignedPermission.class)
.withName(name)
.build();
}
}

View File

@@ -0,0 +1,113 @@
/*
* Copyright (c) 2020 - present Cloudogu GmbH
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3.
*
* 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 Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
package sonia.scm.store.file;
import org.junit.Test;
import sonia.scm.repository.Repository;
import sonia.scm.repository.RepositoryReadOnlyChecker;
import sonia.scm.store.ConfigurationStore;
import sonia.scm.store.StoreObject;
import sonia.scm.store.StoreReadOnlyException;
import sonia.scm.store.StoreTestBase;
import static java.util.Collections.emptySet;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertThrows;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* Unit tests for {@link JAXBConfigurationStore}.
*
*/
public class JAXBConfigurationStoreTest extends StoreTestBase {
private final RepositoryReadOnlyChecker readOnlyChecker = mock(RepositoryReadOnlyChecker.class);
@Override
protected JAXBConfigurationStoreFactory createStoreFactory() {
return new JAXBConfigurationStoreFactory(contextProvider, repositoryLocationResolver, readOnlyChecker, emptySet(), new StoreCacheFactory(new StoreCacheConfigProvider(false)));
}
@Test
public void shouldStoreAndLoadInRepository() {
Repository repository = new Repository("id", "git", "ns", "n");
ConfigurationStore<StoreObject> store = createStoreFactory()
.withType(StoreObject.class)
.withName("test")
.forRepository(repository)
.build();
store.set(new StoreObject("value"));
StoreObject storeObject = store.get();
assertNotNull(storeObject);
assertEquals("value", storeObject.getValue());
}
@Test
public void shouldNotWriteArchivedRepository() {
Repository repository = new Repository("id", "git", "ns", "n");
when(readOnlyChecker.isReadOnly("id")).thenReturn(true);
ConfigurationStore<StoreObject> store = createStoreFactory()
.withType(StoreObject.class)
.withName("test")
.forRepository(repository)
.build();
StoreObject storeObject = new StoreObject("value");
assertThrows(RuntimeException.class, () -> store.set(storeObject));
}
@Test
public void shouldDeleteConfigStore() {
Repository repository = new Repository("id", "git", "ns", "n");
ConfigurationStore<StoreObject> store = createStoreFactory()
.withType(StoreObject.class)
.withName("test")
.forRepository(repository)
.build();
store.set(new StoreObject("value"));
store.delete();
StoreObject storeObject = store.get();
assertThat(storeObject).isNull();
}
@Test
public void shouldNotDeleteStoreForArchivedRepository() {
Repository repository = new Repository("id", "git", "ns", "n");
when(readOnlyChecker.isReadOnly("id")).thenReturn(false);
ConfigurationStore<StoreObject> store = createStoreFactory()
.withType(StoreObject.class)
.withName("test")
.forRepository(repository)
.build();
store.set(new StoreObject());
when(readOnlyChecker.isReadOnly("id")).thenReturn(true);
assertThrows(StoreReadOnlyException.class, store::delete);
assertThat(store.getOptional()).isPresent();
}
}

View File

@@ -0,0 +1,99 @@
/*
* Copyright (c) 2020 - present Cloudogu GmbH
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3.
*
* 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 Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
package sonia.scm.store.file;
import org.junit.Test;
import sonia.scm.repository.Repository;
import sonia.scm.repository.RepositoryReadOnlyChecker;
import sonia.scm.security.UUIDKeyGenerator;
import sonia.scm.store.DataStore;
import sonia.scm.store.DataStoreFactory;
import sonia.scm.store.DataStoreTestBase;
import sonia.scm.store.StoreObject;
import sonia.scm.store.StoreReadOnlyException;
import java.io.File;
import java.io.IOException;
import java.util.Map;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
public class JAXBDataStoreTest extends DataStoreTestBase {
private final RepositoryReadOnlyChecker readOnlyChecker = mock(RepositoryReadOnlyChecker.class);
@Override
protected DataStoreFactory createDataStoreFactory() {
return new JAXBDataStoreFactory(
contextProvider,
repositoryLocationResolver,
new UUIDKeyGenerator(),
readOnlyChecker,
new DataFileCache(null, false),
new StoreCacheFactory(new StoreCacheConfigProvider(false))
);
}
@Override
protected <T> DataStore<T> getDataStore(Class<T> type, Repository repository) {
return createDataStoreFactory()
.withType(type)
.withName("test")
.forRepository(repository)
.build();
}
@Override
protected <T> DataStore<T> getDataStore(Class<T> type) {
return createDataStoreFactory()
.withType(type)
.withName("test")
.build();
}
@Test
public void shouldStoreAndLoadInRepository() {
repoStore.put("abc", new StoreObject("abc_value"));
StoreObject storeObject = repoStore.get("abc");
assertNotNull(storeObject);
assertEquals("abc_value", storeObject.getValue());
}
@Test(expected = StoreReadOnlyException.class)
public void shouldNotStoreForReadOnlyRepository() {
when(readOnlyChecker.isReadOnly(repository.getId())).thenReturn(true);
getDataStore(StoreObject.class, repository).put("abc", new StoreObject("abc_value"));
}
@Test
public void testGetAllWithNonXmlFile() throws IOException {
StoreObject obj1 = new StoreObject("test-1");
store.put("1", obj1);
new File(getTempDirectory(), "var/data/test/no-xml").createNewFile();
Map<String, StoreObject> map = store.getAll();
assertEquals(obj1, map.get("1"));
}
}

View File

@@ -0,0 +1,138 @@
/*
* Copyright (c) 2020 - present Cloudogu GmbH
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3.
*
* 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 Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
package sonia.scm.store.file;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import sonia.scm.SCMContextProvider;
import sonia.scm.io.DefaultFileSystem;
import sonia.scm.repository.InitialRepositoryLocationResolver;
import sonia.scm.repository.RepositoryLocationResolver;
import sonia.scm.repository.xml.PathBasedRepositoryLocationResolver;
import sonia.scm.update.PropertyFileAccess;
import sonia.scm.util.IOUtil;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import static java.util.Collections.emptySet;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.lenient;
@ExtendWith(MockitoExtension.class)
class JAXBPropertyFileAccessTest {
public static final String REPOSITORY_ID = "repoId";
public static final String STORE_NAME = "test";
@Mock
SCMContextProvider contextProvider;
RepositoryLocationResolver locationResolver;
JAXBPropertyFileAccess fileAccess;
@TempDir
private Path tempDir;
@BeforeEach
void initTempDir() {
lenient().when(contextProvider.getBaseDirectory()).thenReturn(tempDir.toFile());
lenient().when(contextProvider.resolve(any())).thenAnswer(invocation -> tempDir.resolve(invocation.getArgument(0).toString()));
locationResolver = new PathBasedRepositoryLocationResolver(contextProvider, new InitialRepositoryLocationResolver(emptySet()), new DefaultFileSystem());
fileAccess = new JAXBPropertyFileAccess(contextProvider, locationResolver);
}
@Test
void shouldRenameGlobalConfigFile() throws IOException {
Path baseDirectory = contextProvider.getBaseDirectory().toPath();
Path configDirectory = baseDirectory.resolve(StoreConstants.CONFIG_DIRECTORY_NAME);
Files.createDirectories(configDirectory);
Path oldPath = configDirectory.resolve("old" + StoreConstants.FILE_EXTENSION);
Files.createFile(oldPath);
fileAccess.renameGlobalConfigurationFrom("old").to("new");
Path newPath = configDirectory.resolve("new" + StoreConstants.FILE_EXTENSION);
assertThat(oldPath).doesNotExist();
assertThat(newPath).exists();
}
@Nested
class ForExistingRepository {
@BeforeEach
void createRepositoryLocation() {
locationResolver.forClass(Path.class).createLocation(REPOSITORY_ID);
}
@Test
void shouldMoveStoreFileToRepositoryBasedLocation() throws IOException {
createV1StoreFile("myStore.xml");
fileAccess.forStoreName(STORE_NAME).moveAsRepositoryStore(Paths.get("myStore.xml"), REPOSITORY_ID);
assertThat(tempDir.resolve("repositories").resolve(REPOSITORY_ID).resolve("store").resolve("data").resolve(STORE_NAME).resolve("myStore.xml")).exists();
}
@Test
void shouldMoveAllStoreFilesToRepositoryBasedLocations() throws IOException {
locationResolver.forClass(Path.class).createLocation("repoId2");
createV1StoreFile(REPOSITORY_ID + ".xml");
createV1StoreFile("repoId2.xml");
PropertyFileAccess.StoreFileTools statisticStoreAccess = fileAccess.forStoreName(STORE_NAME);
statisticStoreAccess.forStoreFiles(statisticStoreAccess::moveAsRepositoryStore);
assertThat(tempDir.resolve("repositories").resolve(REPOSITORY_ID).resolve("store").resolve("data").resolve(STORE_NAME).resolve("repoId.xml")).exists();
assertThat(tempDir.resolve("repositories").resolve("repoId2").resolve("store").resolve("data").resolve(STORE_NAME).resolve("repoId2.xml")).exists();
}
}
private void createV1StoreFile(String name) throws IOException {
Path v1Dir = tempDir.resolve("var").resolve("data").resolve(STORE_NAME);
IOUtil.mkdirs(v1Dir.toFile());
Files.createFile(v1Dir.resolve(name));
}
@Nested
class ForMissingRepository {
@Test
void shouldIgnoreStoreFile() throws IOException {
createV1StoreFile("myStore.xml");
fileAccess.forStoreName(STORE_NAME).moveAsRepositoryStore(Paths.get("myStore.xml"), REPOSITORY_ID);
assertThat(tempDir.resolve("repositories").resolve(REPOSITORY_ID).resolve("store").resolve("data").resolve(STORE_NAME).resolve("myStore.xml")).doesNotExist();
}
}
}

View File

@@ -0,0 +1,169 @@
/*
* Copyright (c) 2020 - present Cloudogu GmbH
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3.
*
* 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 Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
package sonia.scm.store.file;
import jakarta.xml.bind.annotation.XmlAccessType;
import jakarta.xml.bind.annotation.XmlAccessorType;
import jakarta.xml.bind.annotation.XmlRootElement;
import jakarta.xml.bind.annotation.adapters.XmlAdapter;
import jakarta.xml.bind.annotation.adapters.XmlJavaTypeAdapter;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.junit.jupiter.MockitoExtension;
import sonia.scm.store.TypedStoreParameters;
import java.io.File;
import java.net.URL;
import java.net.URLClassLoader;
import java.nio.file.Path;
import java.util.Collections;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicReference;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class TypedStoreContextTest {
@Test
void shouldMarshallAndUnmarshall(@TempDir Path tempDir) {
TypedStoreContext<Sample> context = context();
File file = tempDir.resolve("test.xml").toFile();
context.marshal(new Sample("awesome"), file);
Sample sample = context.unmarshal(file);
assertThat(sample.value).isEqualTo("awesome");
}
@Test
void shouldWorkWithMarshallerAndUnmarshaller(@TempDir Path tempDir) {
TypedStoreContext<Sample> context = context();
File file = tempDir.resolve("test.xml").toFile();
context.withMarshaller(marshaller -> {
marshaller.marshal(new Sample("wow"), file);
});
AtomicReference<Sample> ref = new AtomicReference<>();
context.withUnmarshaller(unmarshaller -> {
Sample sample = (Sample) unmarshaller.unmarshal(file);
ref.set(sample);
});
assertThat(ref.get().value).isEqualTo("wow");
}
@Test
void shouldSetContextClassLoader() {
ClassLoader contextClassLoader = Thread.currentThread().getContextClassLoader();
ClassLoader classLoader = new URLClassLoader(new URL[0], contextClassLoader);
TypedStoreParameters<Sample> params = params(Sample.class);
when(params.getClassLoader()).thenReturn(Optional.of(classLoader));
TypedStoreContext<Sample> context = TypedStoreContext.of(params);
AtomicReference<ClassLoader> ref = new AtomicReference<>();
context.withMarshaller(marshaller -> {
ref.set(Thread.currentThread().getContextClassLoader());
});
assertThat(ref.get()).isSameAs(classLoader);
assertThat(Thread.currentThread().getContextClassLoader()).isSameAs(contextClassLoader);
}
@Test
void shouldConfigureAdapter(@TempDir Path tempDir) {
TypedStoreParameters<SampleWithAdapter> params = params(SampleWithAdapter.class);
when(params.getAdapters()).thenReturn(Collections.singleton(new AppendingAdapter("!")));
TypedStoreContext<SampleWithAdapter> context = TypedStoreContext.of(params);
File file = tempDir.resolve("test.xml").toFile();
context.marshal(new SampleWithAdapter("awesome"), file);
SampleWithAdapter sample = context.unmarshal(file);
// one ! should be added for marshal and one for unmarshal
assertThat(sample.value).isEqualTo("awesome!!");
}
@SuppressWarnings("unchecked")
private <T> TypedStoreContext<T> context() {
return TypedStoreContext.of(params((Class<T>) Sample.class));
}
@SuppressWarnings("unchecked")
private <T> TypedStoreParameters<T> params(Class<T> type) {
TypedStoreParameters<T> params = mock(TypedStoreParameters.class);
when(params.getType()).thenReturn(type);
return params;
}
@XmlRootElement
@XmlAccessorType(XmlAccessType.FIELD)
public static class Sample {
private String value;
public Sample() {
}
public Sample(String value) {
this.value = value;
}
}
@XmlRootElement
@XmlAccessorType(XmlAccessType.FIELD)
public static class SampleWithAdapter {
@XmlJavaTypeAdapter(AppendingAdapter.class)
private String value;
public SampleWithAdapter() {
}
public SampleWithAdapter(String value) {
this.value = value;
}
}
public static class AppendingAdapter extends XmlAdapter<String, String> {
private final String suffix;
public AppendingAdapter(String suffix) {
this.suffix = suffix;
}
@Override
public String unmarshal(String v) {
return v + suffix;
}
@Override
public String marshal(String v) {
return v + suffix;
}
}
}

View File

@@ -0,0 +1,36 @@
/*
* Copyright (c) 2020 - present Cloudogu GmbH
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3.
*
* 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 Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
package sonia.scm.store.sqlite;
import sonia.scm.plugin.QueryableTypeDescriptor;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
public class QueryableTypeDescriptorTestData {
static QueryableTypeDescriptor createDescriptor(String[] t) {
return createDescriptor("com.cloudogu.space.to.be.Spaceship", t);
}
static QueryableTypeDescriptor createDescriptor(String clazz, String[] t) {
QueryableTypeDescriptor descriptor = mock(QueryableTypeDescriptor.class);
lenient().when(descriptor.getTypes()).thenReturn(t);
lenient().when(descriptor.getClazz()).thenReturn(clazz);
lenient().when(descriptor.getName()).thenReturn("");
return descriptor;
}
}

View File

@@ -0,0 +1,134 @@
/*
* Copyright (c) 2020 - present Cloudogu GmbH
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3.
*
* 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 Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
package sonia.scm.store.sqlite;
import lombok.Getter;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.EnumSource;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import sonia.scm.plugin.QueryableTypeDescriptor;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy;
import static org.mockito.Mockito.lenient;
@ExtendWith(MockitoExtension.class)
@SuppressWarnings("java:S115") // we do not heed enum naming conventions for better readability in the test
class SQLiteIdentifiersTest {
@Nested
class Sanitize {
@Getter
private enum BadName {
OneToOne("examplename or 1=1"),
BatchedSQLStatement("105; DROP TABLE Classes"),
CommentOut("--"),
CommentOutWithContent("spaceship'--"),
BlindIfInjection("iif(count(*)>2,\"True\",\"False\")"),
VersionRequest("splite_version()"),
InnocentNameWithSpace("Traumschiff Enterprise");
BadName(String name) {
this.name = name;
}
private final String name;
}
@Getter
private enum GoodName {
Alphabetical("spaceship"),
AlphabeticalWithUnderscore("spaceship_STORE"),
Alphanumerical("rollerCoaster2000"),
AlphanumericalWithUnderscore("rollerCoaster2000_STORE");
GoodName(String name) {
this.name = name;
}
private final String name;
}
@ParameterizedTest
@EnumSource(BadName.class)
void shouldBlockSuspiciousNames(BadName name) {
assertThatThrownBy(() -> SQLiteIdentifiers.sanitize(name.getName()));
}
@ParameterizedTest
@EnumSource(GoodName.class)
void shouldPassCorrectNames(GoodName name) {
String outputName = SQLiteIdentifiers.sanitize(name.getName());
assertThat(outputName).isEqualTo(name.getName());
}
}
@Nested
class ComputeTableName {
@Mock
QueryableTypeDescriptor typeDescriptor;
void setUp(String clazzName, String name) {
lenient().when(typeDescriptor.getClazz()).thenReturn(clazzName);
lenient().when(typeDescriptor.getName()).thenReturn(name);
}
@Test
void shouldReturnCorrectTableNameIncludingPath() {
setUp("sonia.scm.store.sqlite.Spaceship", null);
String output = SQLiteIdentifiers.computeTableName(typeDescriptor);
assertThat(output).isEqualTo("sonia_scm_store_sqlite_Spaceship_STORE");
}
@Test
void shouldReturnTableNameEscapingUnderscores() {
setUp("sonia.scm.store.sqlite.Spaceship_One", null);
String output = SQLiteIdentifiers.computeTableName(typeDescriptor);
assertThat(output).isEqualTo("sonia_scm_store_sqlite_Spaceship__One_STORE");
}
@Test
void shouldReturnCorrectNameWithName() {
setUp("sonia.scm.store.sqlite.Spaceship", "TraumschiffEnterprise");
String output = SQLiteIdentifiers.computeTableName(typeDescriptor);
assertThat(output).isEqualTo("TraumschiffEnterprise_STORE");
}
}
@Nested
class ComputeColumnIdentifier {
@Test
void shouldReturnIdOnlyWithNullValue() {
assertThat(SQLiteIdentifiers.computeColumnIdentifier(null)).isEqualTo("ID");
}
@Test
void shouldReturnCombinedNameWithGivenClassName() {
assertThat(SQLiteIdentifiers.computeColumnIdentifier("sonia.scm.store.sqlite.Spaceship.class")).isEqualTo("Spaceship_ID");
}
}
}

View File

@@ -0,0 +1,139 @@
/*
* Copyright (c) 2020 - present Cloudogu GmbH
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3.
*
* 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 Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
package sonia.scm.store.sqlite;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import sonia.scm.repository.Repository;
import sonia.scm.store.QueryableMaintenanceStore;
import sonia.scm.user.User;
import java.nio.file.Path;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.fail;
@Slf4j
class SQLiteParallelizationTest {
private String connectionString;
@BeforeEach
void init(@TempDir Path path) {
connectionString = "jdbc:sqlite:" + path.toString() + "/test.db";
}
@Test
void shouldTestParallelPutOperations() throws InterruptedException, ExecutionException, SQLException {
int numThreads = 100;
ExecutorService executor = Executors.newFixedThreadPool(numThreads);
List<Future<?>> futures = new ArrayList<>();
SQLiteQueryableMutableStore<User> store = new StoreTestBuilder(connectionString).withIds();
for (int i = 0; i < numThreads; i++) {
final String userId = "user-" + i;
final String userName = "User" + i;
futures.add(executor.submit(() -> {
try {
store.transactional(() -> {
store.put(userId, new User(userName));
return true;
});
} catch (Exception e) {
fail("Error storing user", e);
}
}));
}
for (Future<?> future : futures) {
future.get();
}
executor.shutdown();
int count = actualCount();
assertEquals(numThreads, count, "All threads should have been successfully saved");
}
@Test
void shouldWriteMultipleRowsConcurrently() throws InterruptedException, ExecutionException, SQLException {
int numThreads = 100;
int rowsPerThread = 50;
ExecutorService executor = Executors.newFixedThreadPool(numThreads);
List<Future<?>> futures = new ArrayList<>();
StoreTestBuilder testStoreBuilder = new StoreTestBuilder(connectionString, Repository.class.getName());
QueryableMaintenanceStore<User> store = testStoreBuilder.forMaintenanceWithSubIds("42");
for (int i = 0; i < numThreads; i++) {
final int threadIndex = i;
futures.add(executor.submit(() -> {
List<QueryableMaintenanceStore.Row> rows = new ArrayList<>();
try {
for (int j = 1; j <= rowsPerThread; j++) {
QueryableMaintenanceStore.Row<User> row = new QueryableMaintenanceStore.Row<>(
new String[]{String.valueOf(threadIndex)},
"user-" + threadIndex + "-" + j,
new User("User" + threadIndex + "-" + j, "User " + threadIndex + "-" + j,
"user" + threadIndex + "-" + j + "@example.com")
);
rows.add(row);
}
store.writeAll(rows);
} catch (Exception e) {
fail("Error writing rows", e);
}
}));
}
for (Future<?> future : futures) {
future.get();
}
executor.shutdown();
int expectedCount = numThreads * rowsPerThread;
int count = actualCount();
assertEquals(expectedCount, count, "Exactly " + expectedCount + " entries should have been saved");
}
private int actualCount() throws SQLException {
int count;
try (Connection conn = DriverManager.getConnection(connectionString);
PreparedStatement stmt = conn.prepareStatement("SELECT COUNT(*) FROM sonia_scm_user_User_STORE");
ResultSet rs = stmt.executeQuery()) {
rs.next();
count = rs.getInt(1);
}
return count;
}
}

View File

@@ -0,0 +1,270 @@
/*
* Copyright (c) 2020 - present Cloudogu GmbH
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3.
*
* 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 Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
package sonia.scm.store.sqlite;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import sonia.scm.user.User;
import java.nio.file.Path;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
class SQLiteQueryableMutableStoreTest {
private Connection connection;
private String connectionString;
@BeforeEach
void init(@TempDir Path path) throws SQLException {
connectionString = "jdbc:sqlite:" + path.toString() + "/test.db";
connection = DriverManager.getConnection(connectionString);
}
@Nested
class Put {
@Test
void shouldPutObjectWithoutParent() throws SQLException {
new StoreTestBuilder(connectionString).withIds().put("tricia", new User("trillian"));
ResultSet resultSet = connection
.createStatement()
.executeQuery("SELECT json_extract(u.payload, '$.name') as name FROM sonia_scm_user_User_STORE u WHERE ID = 'tricia'");
assertThat(resultSet.next()).isTrue();
assertThat(resultSet.getString("name")).isEqualTo("trillian");
}
@Test
void shouldOverwriteExistingObject() throws SQLException {
new StoreTestBuilder(connectionString).withIds().put("tricia", new User("Trillian"));
new StoreTestBuilder(connectionString).withIds().put("tricia", new User("McMillan"));
ResultSet resultSet = connection
.createStatement()
.executeQuery("SELECT json_extract(u.payload, '$.name') as name FROM sonia_scm_user_User_STORE u WHERE ID = 'tricia'");
assertThat(resultSet.next()).isTrue();
assertThat(resultSet.getString("name")).isEqualTo("McMillan");
}
@Test
void shouldPutObjectWithSingleParent() throws SQLException {
new StoreTestBuilder(connectionString, "sonia.Group").withIds("42")
.put("tricia", new User("trillian"));
ResultSet resultSet = connection
.createStatement()
.executeQuery("SELECT json_extract(u.payload, '$.name') as name FROM sonia_scm_user_User_STORE u WHERE ID = 'tricia' and GROUP_ID = '42'");
assertThat(resultSet.next()).isTrue();
assertThat(resultSet.getString("name")).isEqualTo("trillian");
}
@Test
void shouldPutObjectWithMultipleParents() throws SQLException {
new StoreTestBuilder(connectionString, "sonia.Company", "sonia.Group").withIds("cloudogu", "42")
.put("tricia", new User("trillian"));
ResultSet resultSet = connection
.createStatement()
.executeQuery("""
SELECT json_extract(u.payload, '$.name') as name
FROM sonia_scm_user_User_STORE u
WHERE ID = 'tricia'
AND GROUP_ID = '42'
AND COMPANY_ID = 'cloudogu'
""");
assertThat(resultSet.next()).isTrue();
assertThat(resultSet.getString("name")).isEqualTo("trillian");
}
@Test
void shouldRollback() throws SQLException {
SQLiteQueryableMutableStore<User> store = new StoreTestBuilder(connectionString).withIds();
store.transactional(() -> {
store.put("tricia", new User("trillian"));
return false;
});
ResultSet resultSet = connection
.createStatement()
.executeQuery("SELECT * FROM sonia_scm_user_User_STORE");
assertThat(resultSet.next()).isFalse();
}
@Test
void shouldDisableAutoCommit() throws SQLException {
SQLiteQueryableMutableStore<User> store = new StoreTestBuilder(connectionString).withIds();
store.transactional(() -> {
store.put("tricia", new User("trillian"));
try {
ResultSet resultSet = connection
.createStatement()
.executeQuery("SELECT * FROM sonia_scm_user_User_STORE");
assertThat(resultSet.next()).isFalse();
} catch (SQLException e) {
throw new RuntimeException(e);
}
return true;
});
ResultSet resultSet = connection
.createStatement()
.executeQuery("SELECT * FROM sonia_scm_user_User_STORE");
assertThat(resultSet.next()).isTrue();
}
}
@Nested
class Get {
@Test
void shouldGetObjectWithoutParent() {
SQLiteQueryableMutableStore<User> store = new StoreTestBuilder(connectionString).withIds();
store.put("tricia", new User("trillian"));
User tricia = store.get("tricia");
assertThat(tricia)
.isNotNull()
.extracting("name")
.isEqualTo("trillian");
}
@Test
void shouldReturnForNotExistingValue() {
SQLiteQueryableMutableStore<User> store = new StoreTestBuilder(connectionString).withIds();
User earth = store.get("earth");
assertThat(earth)
.isNull();
}
@Test
void shouldGetObjectWithSingleParent() {
new StoreTestBuilder(connectionString, new String[]{"sonia.Group"}).withIds("1337").put("tricia", new User("McMillan"));
SQLiteQueryableMutableStore<User> store = new StoreTestBuilder(connectionString, "sonia.Group").withIds("42");
store.put("tricia", new User("trillian"));
User tricia = store.get("tricia");
assertThat(tricia)
.isNotNull()
.extracting("name")
.isEqualTo("trillian");
}
@Test
void shouldGetObjectWithMultipleParents() {
new StoreTestBuilder(connectionString, new String[]{"sonia.Company", "sonia.Group"}).withIds("cloudogu", "1337").put("tricia", new User("McMillan"));
SQLiteQueryableMutableStore<User> store = new StoreTestBuilder(connectionString, "sonia.Company", "sonia.Group").withIds("cloudogu", "42");
store.put("tricia", new User("trillian"));
User tricia = store.get("tricia");
assertThat(tricia)
.isNotNull()
.extracting("name")
.isEqualTo("trillian");
}
@Test
void shouldGetAllForSingleEntry() {
new StoreTestBuilder(connectionString, new String[]{"sonia.Company", "sonia.Group"}).withIds("cloudogu", "1337").put("tricia", new User("McMillan"));
SQLiteQueryableMutableStore<User> store = new StoreTestBuilder(connectionString, "sonia.Company", "sonia.Group").withIds("cloudogu", "42");
store.put("tricia", new User("trillian"));
Map<String, User> users = store.getAll();
assertThat(users).hasSize(1);
assertThat(users.get("tricia"))
.isNotNull()
.extracting("name")
.isEqualTo("trillian");
}
@Test
void shouldGetAllForMultipleEntries() {
SQLiteQueryableMutableStore<User> store = new StoreTestBuilder(connectionString, "sonia.Company", "sonia.Group").withIds("cloudogu", "42");
store.put("dent", new User("arthur"));
store.put("tricia", new User("trillian"));
Map<String, User> users = store.getAll();
assertThat(users).hasSize(2);
assertThat(users.get("tricia"))
.isNotNull()
.extracting("name")
.isEqualTo("trillian");
assertThat(users.get("dent"))
.isNotNull()
.extracting("name")
.isEqualTo("arthur");
}
}
@Nested
class Clear {
@Test
void shouldClear() {
SQLiteQueryableMutableStore<User> uneffectedStore = new StoreTestBuilder(connectionString, "sonia.Company", "sonia.Group").withIds("cloudogu", "1337");
uneffectedStore.put("tricia", new User("McMillan"));
SQLiteQueryableMutableStore<User> store = new StoreTestBuilder(connectionString, "sonia.Company", "sonia.Group").withIds("cloudogu", "42");
store.put("tricia", new User("trillian"));
store.clear();
assertThat(store.getAll()).isEmpty();
assertThat(uneffectedStore.getAll()).hasSize(1);
}
}
@Nested
class Remove {
@Test
void shouldRemove() {
SQLiteQueryableMutableStore<User> store = new StoreTestBuilder(connectionString, "sonia.Company", "sonia.Group").withIds("cloudogu", "42");
store.put("dent", new User("arthur"));
store.put("tricia", new User("trillian"));
store.remove("dent");
assertThat(store.getAll()).containsOnlyKeys("tricia");
}
}
@AfterEach
void closeDB() throws SQLException {
connection.close();
}
}

View File

@@ -0,0 +1,908 @@
/*
* Copyright (c) 2020 - present Cloudogu GmbH
*
* This program is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3.
*
* 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 Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
package sonia.scm.store.sqlite;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import sonia.scm.group.Group;
import sonia.scm.repository.Repository;
import sonia.scm.store.Conditions;
import sonia.scm.store.LeafCondition;
import sonia.scm.store.Operator;
import sonia.scm.store.QueryableMaintenanceStore;
import sonia.scm.store.QueryableMaintenanceStore.MaintenanceIterator;
import sonia.scm.store.QueryableMaintenanceStore.MaintenanceStoreEntry;
import sonia.scm.store.QueryableStore;
import sonia.scm.user.User;
import java.nio.file.Path;
import java.time.Instant;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@SuppressWarnings({"resource", "unchecked"})
class SQLiteQueryableStoreTest {
private String connectionString;
@BeforeEach
void init(@TempDir Path path) {
connectionString = "jdbc:sqlite:" + path.toString() + "/test.db";
}
@Nested
class FindAll {
@Nested
class QueryClassTypes {
@Test
void shouldWorkWithEnums() {
SQLiteQueryableMutableStore<Spaceship> store = new StoreTestBuilder(connectionString).forClassWithIds(Spaceship.class);
store.put(new Spaceship("Space Shuttle", Range.SOLAR_SYSTEM));
store.put(new Spaceship("Heart Of Gold", Range.INTER_GALACTIC));
List<Spaceship> all = store
.query(SPACESHIP_RANGE_ENUM_QUERY_FIELD.eq(Range.SOLAR_SYSTEM))
.findAll();
assertThat(all).hasSize(1);
}
@Test
void shouldWorkWithLongs() {
SQLiteQueryableMutableStore<User> store = new StoreTestBuilder(connectionString).withIds();
User trillian = new User("trillian", "McMillan", "tricia@hog.org");
trillian.setCreationDate(10000000000L);
store.put(trillian);
User arthur = new User("arthur", "Dent", "arthur@hog.org");
arthur.setCreationDate(9999999999L);
store.put(arthur);
List<User> all = store.query(
CREATION_DATE_QUERY_FIELD.lessOrEquals(9999999999L)
)
.findAll();
assertThat(all)
.extracting("name")
.containsExactly("arthur");
}
@Test
void shouldWorkWithIntegers() {
SQLiteQueryableMutableStore<User> store = new StoreTestBuilder(connectionString).withIds();
User trillian = new User("trillian", "McMillan", "tricia@hog.org");
trillian.setCreationDate(42L);
store.put(trillian);
User arthur = new User("arthur", "Dent", "arthur@hog.org");
arthur.setCreationDate(23L);
store.put(arthur);
List<User> all = store.query(
CREATION_DATE_AS_INTEGER_QUERY_FIELD.less(40)
)
.findAll();
assertThat(all)
.extracting("name")
.containsExactly("arthur");
}
@Test
void shouldWorkWithNumberCollection() {
SQLiteQueryableMutableStore<User> store = new StoreTestBuilder(connectionString).withIds();
User trillian = new User("trillian", "McMillan", "tricia@hog.org");
trillian.setActive(true);
store.put(trillian);
User arthur = new User("arthur", "Dent", "arthur@hog.org");
arthur.setActive(false);
store.put(arthur);
List<User> all = store.query(
ACTIVE_QUERY_FIELD.isTrue()
)
.findAll();
assertThat(all)
.extracting("name")
.containsExactly("trillian");
}
@Test
void shouldCountAndWorkWithNumberCollection() {
SQLiteQueryableMutableStore<User> store = new StoreTestBuilder(connectionString).withIds();
User trillian = new User("trillian", "McMillan", "tricia@hog.org");
trillian.setActive(true);
store.put(trillian);
User arthur = new User("arthur", "Dent", "arthur@hog.org");
arthur.setActive(false);
store.put(arthur);
long count = store.query(
ACTIVE_QUERY_FIELD.isTrue()
)
.count();
assertThat(count).isEqualTo(1);
}
}
@Nested
class QueryFeatures {
@Test
void shouldHandleCollections() {
SQLiteQueryableMutableStore<Spaceship> store = new StoreTestBuilder(connectionString).forClassWithIds(Spaceship.class);
store.put(new Spaceship("Spaceshuttle", "Buzz", "Anndre"));
store.put(new Spaceship("Heart Of Gold", "Trillian", "Arthur", "Ford", "Zaphod", "Marvin"));
List<Spaceship> result = store.query(
SPACESHIP_CREW_QUERY_FIELD.contains("Marvin")
).findAll();
assertThat(result).hasSize(1);
}
@Test
void shouldCountAndHandleCollections() {
SQLiteQueryableMutableStore<Spaceship> store = new StoreTestBuilder(connectionString).forClassWithIds(Spaceship.class);
store.put(new Spaceship("Spaceshuttle", "Buzz", "Anndre"));
store.put(new Spaceship("Heart Of Gold", "Trillian", "Arthur", "Ford", "Zaphod", "Marvin"));
long result = store.query(
SPACESHIP_CREW_QUERY_FIELD.contains("Marvin")
).count();
assertThat(result).isEqualTo(1);
}
@Test
void shouldCountWithoutConditions() {
SQLiteQueryableMutableStore<Spaceship> store = new StoreTestBuilder(connectionString).forClassWithIds(Spaceship.class);
store.put(new Spaceship("Spaceshuttle", "Buzz", "Anndre"));
store.put(new Spaceship("Heart Of Gold", "Trillian", "Arthur", "Ford", "Zaphod", "Marvin"));
long result = store.query().count();
assertThat(result).isEqualTo(2);
}
@Test
void shouldHandleCollectionSize() {
SQLiteQueryableMutableStore<Spaceship> store = new StoreTestBuilder(connectionString).forClassWithIds(Spaceship.class);
store.put(new Spaceship("Spaceshuttle", "Buzz", "Anndre"));
store.put(new Spaceship("Heart of Gold", "Trillian", "Arthur", "Ford", "Zaphod", "Marvin"));
store.put(new Spaceship("MillenniumFalcon"));
List<Spaceship> onlyEmpty = store.query(
SPACESHIP_CREW_SIZE_QUERY_FIELD.isEmpty()
).findAll();
assertThat(onlyEmpty).hasSize(1);
assertThat(onlyEmpty.get(0).getName()).isEqualTo("MillenniumFalcon");
List<Spaceship> exactlyTwoCrewMates = store.query(
SPACESHIP_CREW_SIZE_QUERY_FIELD.eq(2L)
).findAll();
assertThat(exactlyTwoCrewMates).hasSize(1);
assertThat(exactlyTwoCrewMates.get(0).getName()).isEqualTo("Spaceshuttle");
List<Spaceship> moreThanTwoCrewMates = store.query(
SPACESHIP_CREW_SIZE_QUERY_FIELD.greater(2L)
).findAll();
assertThat(moreThanTwoCrewMates).hasSize(1);
assertThat(moreThanTwoCrewMates.get(0).getName()).isEqualTo("Heart of Gold");
}
@Test
void shouldHandleMap() {
SQLiteQueryableMutableStore<Spaceship> store = new StoreTestBuilder(connectionString).forClassWithIds(Spaceship.class);
store.put(new Spaceship("Spaceshuttle", Map.of("moon", true, "earth", true)));
store.put(new Spaceship("Heart of Gold", Map.of("vogon", true, "earth", true)));
store.put(new Spaceship("MillenniumFalcon", Map.of("dagobah", false)));
List<Spaceship> keyResult = store.query(
SPACESHIP_DESTINATIONS_QUERY_FIELD.containsKey("vogon")
).findAll();
assertThat(keyResult).hasSize(1);
assertThat(keyResult.get(0).getName()).isEqualTo("Heart of Gold");
List<Spaceship> valueResult = store.query(
SPACESHIP_DESTINATIONS_QUERY_FIELD.containsValue(false)
).findAll();
assertThat(valueResult).hasSize(1);
assertThat(valueResult.get(0).getName()).isEqualTo("MillenniumFalcon");
}
@Test
void shouldCountAndHandleMap() {
SQLiteQueryableMutableStore<Spaceship> store = new StoreTestBuilder(connectionString).forClassWithIds(Spaceship.class);
store.put(new Spaceship("Spaceshuttle", Map.of("moon", true, "earth", true)));
store.put(new Spaceship("Heart of Gold", Map.of("vogon", true, "earth", true)));
store.put(new Spaceship("MillenniumFalcon", Map.of("dagobah", false)));
long keyResult = store.query(
SPACESHIP_DESTINATIONS_QUERY_FIELD.containsKey("vogon")
).count();
assertThat(keyResult).isEqualTo(1);
long valueResult = store.query(
SPACESHIP_DESTINATIONS_QUERY_FIELD.containsValue(false)
).count();
assertThat(valueResult).isEqualTo(1);
}
@Test
void shouldHandleMapSize() {
SQLiteQueryableMutableStore<Spaceship> store = new StoreTestBuilder(connectionString).forClassWithIds(Spaceship.class);
store.put(new Spaceship("Spaceshuttle", Map.of("moon", true, "earth", true)));
store.put(new Spaceship("Heart of Gold", Map.of("vogon", true, "earth", true, "dagobah", true)));
store.put(new Spaceship("MillenniumFalcon", Map.of()));
List<Spaceship> onlyEmpty = store.query(
SPACESHIP_DESTINATIONS_SIZE_QUERY_FIELD.isEmpty()
).findAll();
assertThat(onlyEmpty).hasSize(1);
assertThat(onlyEmpty.get(0).getName()).isEqualTo("MillenniumFalcon");
List<Spaceship> exactlyTwoDestinations = store.query(
SPACESHIP_DESTINATIONS_SIZE_QUERY_FIELD.eq(2L)
).findAll();
assertThat(exactlyTwoDestinations).hasSize(1);
assertThat(exactlyTwoDestinations.get(0).getName()).isEqualTo("Spaceshuttle");
List<Spaceship> moreThanTwoDestinations = store.query(
SPACESHIP_DESTINATIONS_SIZE_QUERY_FIELD.greater(2L)
).findAll();
assertThat(moreThanTwoDestinations).hasSize(1);
assertThat(moreThanTwoDestinations.get(0).getName()).isEqualTo("Heart of Gold");
}
@Test
void shouldRetrieveTime() {
SQLiteQueryableMutableStore<Spaceship> store = new StoreTestBuilder(connectionString).forClassWithIds(Spaceship.class);
Spaceship spaceshuttle = new Spaceship("Spaceshuttle", Range.SOLAR_SYSTEM);
spaceshuttle.setInServiceSince(Instant.parse("1981-04-12T10:00:00Z"));
store.put(spaceshuttle);
Spaceship falcon = new Spaceship("Falcon9", Range.SOLAR_SYSTEM);
falcon.setInServiceSince(Instant.parse("2015-12-21T10:00:00Z"));
store.put(falcon);
List<Spaceship> resultEqOperator = store.query(
SPACESHIP_INSERVICE_QUERY_FIELD.eq(Instant.parse("2015-12-21T10:00:00Z"))).findAll();
assertThat(resultEqOperator).hasSize(1);
assertThat(resultEqOperator.get(0).getName()).isEqualTo("Falcon9");
List<Spaceship> resultBeforeOperator = store.query(
SPACESHIP_INSERVICE_QUERY_FIELD.before(Instant.parse("2000-12-21T10:00:00Z"))).findAll();
assertThat(resultBeforeOperator).hasSize(1);
assertThat(resultBeforeOperator.get(0).getName()).isEqualTo("Spaceshuttle");
List<Spaceship> resultAfterOperator = store.query(
SPACESHIP_INSERVICE_QUERY_FIELD.after(Instant.parse("2000-01-01T00:00:00Z"))).findAll();
assertThat(resultAfterOperator).hasSize(1);
assertThat(resultAfterOperator.get(0).getName()).isEqualTo("Falcon9");
List<Spaceship> resultBetweenOperator = store.query(
SPACESHIP_INSERVICE_QUERY_FIELD.between(Instant.parse("1980-04-12T10:00:00Z"), Instant.parse("2016-12-21T10:00:00Z"))).findAll();
assertThat(resultBetweenOperator).hasSize(2);
}
@Test
void shouldLimitQuery() {
SQLiteQueryableMutableStore<User> store = new StoreTestBuilder(connectionString).withIds();
store.put(new User("trillian", "McMillan", "tricia@hog.org"));
store.put(new User("arthur", "Dent", "arthur@hog.org"));
store.put(new User("zaphod", "Beeblebrox", "zaphod@hog.org"));
store.put(new User("marvin", "Marvin", "marvin@hog.org"));
List<User> all = store.query()
.findAll(1, 2);
assertThat(all)
.extracting("name")
.containsExactly("arthur", "zaphod");
}
@Test
void shouldOrderResults() {
SQLiteQueryableMutableStore<User> store = new StoreTestBuilder(connectionString).withIds();
store.put(new User("trillian", "McMillan", "tricia@hog.org"));
store.put(new User("arthur", "Dent", "arthur@hog.org"));
store.put(new User("zaphod", "Beeblebrox Head 1", "zaphod1@hog.org"));
store.put(new User("zaphod", "Beeblebrox Head 2", "zaphod2@hog.org"));
store.put(new User("marvin", "Marvin", "marvin@hog.org"));
List<User> all = store.query()
.orderBy(USER_NAME_QUERY_FIELD, QueryableStore.Order.ASC)
.orderBy(DISPLAY_NAME_QUERY_FIELD, QueryableStore.Order.DESC)
.findAll();
assertThat(all)
.extracting("mail")
.containsExactly("arthur@hog.org", "marvin@hog.org", "tricia@hog.org", "zaphod2@hog.org", "zaphod1@hog.org");
}
}
@Nested
class QueryLogicalHandling {
@Test
void shouldQueryForId() {
SQLiteQueryableMutableStore<User> store = new StoreTestBuilder(connectionString).withIds();
store.put("1", new User("trillian", "Tricia", "tricia@hog.org"));
store.put("2", new User("trillian", "Trillian McMillan", "mcmillan@gmail.com"));
store.put("3", new User("arthur", "Arthur Dent", "arthur@hog.org"));
List<User> all = store.query(
ID_QUERY_FIELD.eq("1")
)
.findAll();
assertThat(all)
.extracting("displayName")
.containsExactly("Tricia");
}
@Test
void shouldQueryForParents() {
new StoreTestBuilder(connectionString, Group.class.getName())
.withIds("42")
.put("tricia", new User("trillian", "Tricia", "tricia@hog.org"));
new StoreTestBuilder(connectionString, Group.class.getName())
.withIds("1337")
.put("tricia", new User("trillian", "Trillian McMillan", "tricia@hog.org"));
SQLiteQueryableStore<User> store = new StoreTestBuilder(connectionString, Group.class.getName()).withIds();
List<User> all = store.query(
GROUP_QUERY_FIELD.eq("42")
)
.findAll();
assertThat(all)
.extracting("displayName")
.containsExactly("Tricia");
}
@Test
void shouldHandleContainsCondition() {
SQLiteQueryableMutableStore<User> store = new StoreTestBuilder(connectionString).withIds();
store.put("tricia", new User("trillian", "Tricia", "tricia@hog.org"));
store.put("McMillan", new User("trillian", "Trillian McMillan", "mcmillan@gmail.com"));
store.put("dent", new User("arthur", "Arthur Dent", "arthur@hog.org"));
List<User> all = store.query(
USER_NAME_QUERY_FIELD.contains("ri")
)
.findAll();
assertThat(all).hasSize(2);
}
@Test
void shouldHandleIsNullCondition() {
SQLiteQueryableMutableStore<User> store = new StoreTestBuilder(connectionString).withIds();
store.put("tricia", new User("trillian", null, "tricia@hog.org"));
store.put("dent", new User("arthur", "Arthur Dent", "arthur@hog.org"));
List<User> all = store.query(
DISPLAY_NAME_QUERY_FIELD.isNull()
)
.findAll();
assertThat(all)
.extracting("name")
.containsExactly("trillian");
}
@Test
void shouldHandleNotNullCondition() {
SQLiteQueryableMutableStore<User> store = new StoreTestBuilder(connectionString).withIds();
store.put("tricia", new User("trillian", null, "tricia@hog.org"));
store.put("dent", new User("arthur", "Arthur Dent", "arthur@hog.org"));
List<User> all = store.query(
Conditions.not(DISPLAY_NAME_QUERY_FIELD.isNull())
)
.findAll();
assertThat(all)
.extracting("name")
.containsExactly("arthur");
}
@Test
void shouldHandleOr() {
SQLiteQueryableMutableStore<User> store = new StoreTestBuilder(connectionString).withIds();
store.put("tricia", new User("trillian", "Tricia", "tricia@hog.org"));
store.put("McMillan", new User("trillian", "Trillian McMillan", "mcmillan@gmail.com"));
store.put("dent", new User("arthur", "Arthur Dent", "arthur@hog.org"));
List<User> all = store.query(
Conditions.or(
DISPLAY_NAME_QUERY_FIELD.eq("Tricia"),
DISPLAY_NAME_QUERY_FIELD.eq("Trillian McMillan")
)
)
.findAll();
assertThat(all)
.extracting("name")
.containsExactly("trillian", "trillian");
}
@Test
void shouldHandleOrWithMultipleStores() {
SQLiteQueryableMutableStore<User> store = new StoreTestBuilder(connectionString, "sonia.Group").withIds("CoolGroup");
User tricia = new User("trillian", "Tricia", "tricia@hog.org");
User mcmillan = new User("trillian", "Trillian McMillan", "mcmillan@gmail.com");
User dent = new User("arthur", "Arthur Dent", "arthur@hog.org");
store.put("tricia", tricia);
store.put("McMillan", mcmillan);
store.put("dent", dent);
SQLiteQueryableMutableStore<User> parallelStore = new StoreTestBuilder(connectionString, "sonia.Group").withIds("LameGroup");
parallelStore.put("tricia", new User("trillian", "Trillian IAMINAPARALLELSTORE McMillan", "mcmillan@gmail.com"));
List<User> result = store.query(
Conditions.or(
new LeafCondition<>(new QueryableStore.StringQueryField<>("mail"), Operator.EQ, "arthur@hog.org"),
new LeafCondition<>(new QueryableStore.StringQueryField<>("mail"), Operator.EQ, "mcmillan@gmail.com"))
).findAll();
assertThat(result).containsExactlyInAnyOrder(dent, mcmillan);
}
@Test
void shouldHandleGroup() {
SQLiteQueryableMutableStore<User> store = new StoreTestBuilder(connectionString, "sonia.Group")
.withIds("42");
store.put("tricia", new User("trillian", "Tricia", "tricia@hog.org"));
new StoreTestBuilder(connectionString, "sonia.Group")
.withIds("1337")
.put("tricia", new User("trillian", "Trillian McMillan", "tricia@hog.org"));
List<User> all = store.query().findAll();
assertThat(all)
.extracting("displayName")
.containsExactly("Tricia");
}
@Test
void shouldHandleGroupWithCondition() {
SQLiteQueryableMutableStore<User> store = new StoreTestBuilder(connectionString, "sonia.Group")
.withIds("42");
store
.put("tricia", new User("trillian", "Tricia", "tricia@hog.org"));
new StoreTestBuilder(connectionString, "sonia.Group")
.withIds("1337")
.put("tricia", new User("trillian", "Trillian McMillan", "tricia@hog.org"));
List<User> all = store.query(USER_NAME_QUERY_FIELD.eq("trillian")).findAll();
assertThat(all)
.extracting("displayName")
.containsExactly("Tricia");
}
@Test
void shouldHandleInArrayCondition() {
SQLiteQueryableMutableStore<User> store = new StoreTestBuilder(connectionString).withIds();
store.put(new User("trillian", "McMillan", "tricia@hog.org"));
store.put(new User("arthur", "Dent", "arthur@hog.org"));
store.put(new User("zaphod", "Beeblebrox", "zaphod@hog.org"));
List<User> all = store.query(
USER_NAME_QUERY_FIELD.in("trillian", "arthur")
)
.findAll();
assertThat(all)
.extracting("name")
.containsExactly("trillian", "arthur");
}
}
@Test
void shouldFindAllObjectsWithoutParentWithoutConditions() {
SQLiteQueryableMutableStore<User> store = new StoreTestBuilder(connectionString).withIds();
store.put("tricia", new User("trillian"));
List<User> all = store.query().findAll();
assertThat(all).hasSize(1);
}
@Test
void shouldFindAllObjectsWithoutParentWithCondition() {
SQLiteQueryableMutableStore<User> store = new StoreTestBuilder(connectionString).withIds();
store.put("tricia", new User("trillian"));
store.put("dent", new User("arthur"));
List<User> all = store.query(USER_NAME_QUERY_FIELD.eq("trillian")).findAll();
assertThat(all).hasSize(1);
}
@Test
void shouldFindAllObjectsWithOneParentAndMultipleConditions() {
SQLiteQueryableMutableStore<User> store = new StoreTestBuilder(connectionString, "sonia.Group").withIds("CoolGroup");
User tricia = new User("trillian", "Tricia", "tricia@hog.org");
User mcmillan = new User("trillian", "Trillian McMillan", "mcmillan@gmail.com");
User dent = new User("arthur", "Arthur Dent", "arthur@hog.org");
store.put("tricia", tricia);
store.put("McMillan", mcmillan);
store.put("dent", dent);
List<User> result = store.query(
Conditions.or(
new LeafCondition<>(new QueryableStore.StringQueryField<>("mail"), Operator.EQ, "arthur@hog.org"),
new LeafCondition<>(new QueryableStore.StringQueryField<>("mail"), Operator.EQ, "mcmillan@gmail.com"))
).findAll();
assertThat(result).containsExactlyInAnyOrder(dent, mcmillan);
}
@Test
void shouldFindAllObjectsWithoutParentWithMultipleConditions() {
SQLiteQueryableMutableStore<User> store = new StoreTestBuilder(connectionString).withIds();
store.put("tricia", new User("trillian", "Tricia", "tricia@hog.org"));
store.put("McMillan", new User("trillian", "Trillian McMillan", "mcmillan@gmail.com"));
store.put("dent", new User("arthur", "Arthur Dent", "arthur@hog.org"));
List<User> all = store.query(
USER_NAME_QUERY_FIELD.eq("trillian"),
DISPLAY_NAME_QUERY_FIELD.eq("Tricia")
)
.findAll();
assertThat(all).hasSize(1);
}
@Test
void shouldReturnIds() {
SQLiteQueryableMutableStore<User> store = new StoreTestBuilder(connectionString, Spaceship.class.getName())
.withIds("hog");
store.put("tricia", new User("trillian", "Tricia", "tricia@hog.org"));
List<QueryableStore.Result<User>> results = store
.query()
.withIds()
.findAll();
assertThat(results).hasSize(1);
QueryableStore.Result<User> result = results.get(0);
assertThat(result.getParentId(Spaceship.class)).contains("hog");
assertThat(result.getId()).isEqualTo("tricia");
assertThat(result.getEntity().getName()).isEqualTo("trillian");
}
}
@Nested
class FindOne {
@Test
void shouldReturnEmptyOptionalIfNoResultFound() {
SQLiteQueryableMutableStore<Spaceship> store = new StoreTestBuilder(connectionString).forClassWithIds(Spaceship.class);
assertThat(store.query(SPACESHIP_NAME_QUERY_FIELD.eq("Heart Of Gold")).findOne()).isEmpty();
}
@Test
void shouldReturnOneResultIfOneIsGiven() {
SQLiteQueryableMutableStore<Spaceship> store = new StoreTestBuilder(connectionString).forClassWithIds(Spaceship.class);
Spaceship expectedShip = new Spaceship("Heart Of Gold", Range.INNER_GALACTIC);
store.put(expectedShip);
Spaceship ship = store.query(SPACESHIP_NAME_QUERY_FIELD.eq("Heart Of Gold")).findOne().get();
assertThat(ship).isEqualTo(expectedShip);
}
@Test
void shouldThrowErrorIfMoreThanOneResultIsSaved() {
SQLiteQueryableMutableStore<Spaceship> store = new StoreTestBuilder(connectionString).forClassWithIds(Spaceship.class);
Spaceship expectedShip = new Spaceship("Heart Of Gold", Range.INNER_GALACTIC);
Spaceship localShip = new Spaceship("Heart Of Gold", Range.SOLAR_SYSTEM);
store.put(expectedShip);
store.put(localShip);
assertThatThrownBy(() -> store.query(SPACESHIP_NAME_QUERY_FIELD.eq("Heart Of Gold")).findOne().get())
.isInstanceOf(QueryableStore.TooManyResultsException.class);
}
}
@Nested
class FindFirst {
@Test
void shouldFindFirst() {
SQLiteQueryableMutableStore<User> store = new StoreTestBuilder(connectionString).withIds();
User expectedUser = new User("trillian", "Tricia", "tricia@hog.org");
store.put("1", expectedUser);
store.put("2", new User("trillian", "Trillian McMillan", "mcmillan@gmail.com"));
store.put("3", new User("arthur", "Arthur Dent", "arthur@hog.org"));
Optional<User> user = store.query(
USER_NAME_QUERY_FIELD.eq("trillian")
)
.findFirst();
assertThat(user).isEqualTo(Optional.of(expectedUser));
}
@Test
void shouldFindFirstWithMatchingCondition() {
SQLiteQueryableMutableStore<User> store = new StoreTestBuilder(connectionString).withIds();
User expectedUser = new User("trillian", "Trillian McMillan", "mcmillan-alternate@gmail.com");
store.put("1", new User("trillian", "Tricia", "tricia@hog.org"));
store.put("2", new User("trillian", "Trillian McMillan", "mcmillan@gmail.com"));
store.put("3", expectedUser);
store.put("4", new User("arthur", "Arthur Dent", "arthur@hog.org"));
Optional<User> user = store.query(
USER_NAME_QUERY_FIELD.eq("trillian"),
MAIL_QUERY_FIELD.eq("mcmillan-alternate@gmail.com")
)
.findFirst();
assertThat(user).isEqualTo(Optional.of(expectedUser));
}
@Test
void shouldFindFirstWithMatchingLogicalCondition() {
SQLiteQueryableMutableStore<User> store = new StoreTestBuilder(connectionString).withIds();
User expectedUser = new User("trillian", "Trillian McMillan", "mcmillan@gmail.com");
store.put("1", new User("trillian-old", "Tricia", "tricia@hog.org"));
store.put("2", new User("trillian", "Trillian McMillan", "mcmillan@gmail.com"));
store.put("3", expectedUser);
store.put("4", new User("arthur", "Arthur Dent", "arthur@hog.org"));
store.put("5", new User("arthur", "Trillian McMillan", "mcmillan@gmail.com"));
Optional<User> user = store.query(
Conditions.and(
Conditions.and(
DISPLAY_NAME_QUERY_FIELD.eq("Trillian McMillan"),
MAIL_QUERY_FIELD.eq("mcmillan@gmail.com")
),
Conditions.not(
ID_QUERY_FIELD.eq("1")
)
)
).findFirst();
assertThat(user).isEqualTo(Optional.of(expectedUser));
}
@Test
void shouldReturnEmptyOptionalIfNoResultFound() {
SQLiteQueryableMutableStore<User> store = new StoreTestBuilder(connectionString).withIds();
Optional<User> user = store.query(
USER_NAME_QUERY_FIELD.eq("dave")
)
.findFirst();
assertThat(user).isEmpty();
}
}
@Nested
class ForMaintenance {
@Test
void shouldUpdateRawJson() throws Exception {
SQLiteQueryableMutableStore<User> store = new StoreTestBuilder(connectionString).withIds();
User user = new User("trillian", "Trillian McMillan", "mcmillan@gmail.com");
store.put("1", user);
try (MaintenanceIterator<User> iterator = store.iterateAll()) {
assertThat(iterator.hasNext()).isTrue();
MaintenanceStoreEntry<User> entry = iterator.next();
assertThat(entry.getId()).isEqualTo("1");
User userFromIterator = entry.get();
userFromIterator.setName("dent");
entry.update(userFromIterator);
assertThat(iterator.hasNext()).isFalse();
}
User changedUser = store.get("1");
assertThat(changedUser.getName()).isEqualTo("dent");
}
@Test
void shouldUpdateRawJsonForItemWithParent() throws Exception {
SQLiteQueryableMutableStore<User> subStore = new StoreTestBuilder(connectionString, Group.class.getName()).withIds("hitchhiker");
User user = new User("trillian", "Trillian McMillan", "mcmillan@gmail.com");
subStore.put("1", user);
QueryableMaintenanceStore<User> maintenanceStore = new StoreTestBuilder(connectionString, Group.class.getName()).forMaintenanceWithSubIds();
try (MaintenanceIterator<User> iterator = maintenanceStore.iterateAll()) {
assertThat(iterator.hasNext()).isTrue();
MaintenanceStoreEntry<User> entry = iterator.next();
assertThat(entry.getId()).isEqualTo("1");
User userFromIterator = entry.get();
userFromIterator.setName("dent");
entry.update(userFromIterator);
assertThat(iterator.hasNext()).isFalse();
}
User changedUser = subStore.get("1");
assertThat(changedUser.getName()).isEqualTo("dent");
}
@Test
void shouldRemoveFromIteratorWithoutParent() {
SQLiteQueryableMutableStore<User> store = new StoreTestBuilder(connectionString).withIds();
store.put(new User("trillian", "Trillian McMillan", "mcmillan@gmail.com"));
store.put(new User("dent", "Arthur Dent", "dent@gmail.com"));
for (MaintenanceIterator<User> iter = store.iterateAll(); iter.hasNext(); ) {
MaintenanceStoreEntry<User> next = iter.next();
if (next.get().getName().equals("dent")) {
iter.remove();
}
}
assertThat(store.getAll())
.values()
.extracting("name")
.containsExactly("trillian");
}
@Test
void shouldRemoveFromIteratorWithParents() {
StoreTestBuilder testStoreBuilder = new StoreTestBuilder(connectionString, Repository.class.getName(), Group.class.getName());
SQLiteQueryableMutableStore<User> hogStore = testStoreBuilder.withIds("42", "hog");
hogStore.put("trisha", new User("trillian", "Trillian McMillan", "mcmillan@hog.com"));
hogStore.put("dent", new User("dent", "Arthur Dent", "dent@hog.com"));
SQLiteQueryableMutableStore<User> earthStore = testStoreBuilder.withIds("42", "earth");
earthStore.put("dent", new User("dent", "Arthur Dent", "dent@gmail.com"));
QueryableMaintenanceStore<User> store = testStoreBuilder.forMaintenanceWithSubIds("42");
for (MaintenanceIterator<User> iter = store.iterateAll(); iter.hasNext(); ) {
MaintenanceStoreEntry<User> next = iter.next();
if (next.get().getName().equals("dent") && next.getParentId(Group.class).get().equals("hog")) {
iter.remove();
}
}
assertThat(testStoreBuilder.withIds("42", "hog").getAll())
.values()
.extracting("name")
.containsExactly("trillian");
assertThat(testStoreBuilder.withIds("42", "earth").getAll())
.values()
.extracting("name")
.containsExactly("dent");
}
@Test
void shouldReadAll() {
StoreTestBuilder testStoreBuilder = new StoreTestBuilder(connectionString, Repository.class.getName());
SQLiteQueryableMutableStore<User> hogStore = testStoreBuilder.withIds("42");
hogStore.put("trisha", new User("trillian", "Trillian McMillan", "mcmillan@hog.com"));
hogStore.put("dent", new User("dent", "Arthur Dent", "dent@hog.com"));
QueryableMaintenanceStore<User> store = testStoreBuilder.forMaintenanceWithSubIds("42");
Collection<QueryableMaintenanceStore.Row<User>> rows = store.readAll();
assertThat(rows)
.extracting("id")
.containsExactlyInAnyOrder("dent", "trisha");
assertThat(rows)
.extracting(QueryableMaintenanceStore.Row::getParentIds)
.allSatisfy(strings -> assertThat(strings).containsExactly("42"));
assertThat(rows)
.extracting(QueryableMaintenanceStore.Row::getValue)
.extracting("name")
.containsExactlyInAnyOrder("trillian", "dent");
}
@Test
void shouldWriteAllForNewParent() {
StoreTestBuilder testStoreBuilder = new StoreTestBuilder(connectionString, Repository.class.getName());
QueryableMaintenanceStore<User> store = testStoreBuilder.forMaintenanceWithSubIds("42");
store.writeAll(
List.of(
new QueryableMaintenanceStore.Row<>(new String[]{"23"}, "trisha", new User("trillian", "Trillian McMillan", "trisha@hog.com"))
)
);
SQLiteQueryableMutableStore<User> hogStore = testStoreBuilder.withIds("42");
Collection<QueryableMaintenanceStore.Row<User>> allValues = hogStore.readAll();
assertThat(allValues)
.extracting("value")
.extracting("name")
.containsExactly("trillian");
}
@Test
void shouldWriteRawForNewParent() {
StoreTestBuilder testStoreBuilder = new StoreTestBuilder(connectionString, Repository.class.getName());
QueryableMaintenanceStore<User> store = testStoreBuilder.forMaintenanceWithSubIds("42");
store.writeRaw(
List.of(
new QueryableMaintenanceStore.RawRow(new String[]{"23"}, "trisha", "{ \"name\": \"trillian\", \"displayName\": \"Trillian McMillan\", \"mail\": \"mcmillan@hog.com\" }")
)
);
SQLiteQueryableMutableStore<User> hogStore = testStoreBuilder.withIds("42");
Collection<QueryableMaintenanceStore.Row<User>> allValues = hogStore.readAll();
assertThat(allValues)
.extracting("value")
.extracting("name")
.containsExactly("trillian");
}
}
private static final QueryableStore.IdQueryField<User> ID_QUERY_FIELD =
new QueryableStore.IdQueryField<>();
private static final QueryableStore.IdQueryField<User> GROUP_QUERY_FIELD =
new QueryableStore.IdQueryField<>(Group.class);
private static final QueryableStore.StringQueryField<User> USER_NAME_QUERY_FIELD =
new QueryableStore.StringQueryField<>("name");
private static final QueryableStore.StringQueryField<User> DISPLAY_NAME_QUERY_FIELD =
new QueryableStore.StringQueryField<>("displayName");
private static final QueryableStore.StringQueryField<User> MAIL_QUERY_FIELD =
new QueryableStore.StringQueryField<>("mail");
private static final QueryableStore.NumberQueryField<User, Long> CREATION_DATE_QUERY_FIELD =
new QueryableStore.NumberQueryField<>("creationDate");
private static final QueryableStore.NumberQueryField<User, Integer> CREATION_DATE_AS_INTEGER_QUERY_FIELD =
new QueryableStore.NumberQueryField<>("creationDate");
private static final QueryableStore.BooleanQueryField<User> ACTIVE_QUERY_FIELD =
new QueryableStore.BooleanQueryField<>("active");
enum Range {
SOLAR_SYSTEM, INNER_GALACTIC, INTER_GALACTIC
}
private static final QueryableStore.StringQueryField<Spaceship> SPACESHIP_NAME_QUERY_FIELD =
new QueryableStore.StringQueryField<>("name");
private static final QueryableStore.EnumQueryField<Spaceship, Range> SPACESHIP_RANGE_ENUM_QUERY_FIELD =
new QueryableStore.EnumQueryField<>("range");
private static final QueryableStore.CollectionQueryField<Spaceship> SPACESHIP_CREW_QUERY_FIELD =
new QueryableStore.CollectionQueryField<>("crew");
private static final QueryableStore.CollectionSizeQueryField<Spaceship> SPACESHIP_CREW_SIZE_QUERY_FIELD =
new QueryableStore.CollectionSizeQueryField<>("crew");
private static final QueryableStore.MapQueryField<Spaceship> SPACESHIP_DESTINATIONS_QUERY_FIELD =
new QueryableStore.MapQueryField<>("destinations");
private static final QueryableStore.MapSizeQueryField<Spaceship> SPACESHIP_DESTINATIONS_SIZE_QUERY_FIELD =
new QueryableStore.MapSizeQueryField<>("destinations");
private static final QueryableStore.InstantQueryField<Spaceship> SPACESHIP_INSERVICE_QUERY_FIELD =
new QueryableStore.InstantQueryField<>("inServiceSince");
}

Some files were not shown because too many files have changed in this diff Show More