mirror of
https://github.com/scm-manager/scm-manager.git
synced 2026-03-21 19:41:36 +01:00
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:
219
scm-persistence/src/main/java/sonia/scm/CopyOnWrite.java
Normal file
219
scm-persistence/src/main/java/sonia/scm/CopyOnWrite.java
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() + ")";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 + ")";
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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('$', '.'));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
175
scm-persistence/src/main/java/sonia/scm/xml/AbstractXmlDAO.java
Normal file
175
scm-persistence/src/main/java/sonia/scm/xml/AbstractXmlDAO.java
Normal 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);
|
||||
}
|
||||
|
||||
}
|
||||
53
scm-persistence/src/main/java/sonia/scm/xml/XmlDatabase.java
Normal file
53
scm-persistence/src/main/java/sonia/scm/xml/XmlDatabase.java
Normal 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);
|
||||
}
|
||||
288
scm-persistence/src/main/java/sonia/scm/xml/XmlStreams.java
Normal file
288
scm-persistence/src/main/java/sonia/scm/xml/XmlStreams.java
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user