From 60b672cf59c3c7226c4aa5c0abceae4e93d327c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Till-Andr=C3=A9=20Diegeler?= Date: Tue, 10 Jun 2025 15:59:31 +0200 Subject: [PATCH] Introduce retain and deleteAll for queryable stores --- docs/en/development/storage.md | 16 ++ gradle/changelog/extend_sqlite_api.yaml | 2 + .../scm/store/QueryableMutableStore.java | 49 +++++ .../java/sonia/scm/store/QueryableStore.java | 8 +- .../scm/store/sqlite/SQLSelectStatement.java | 3 + .../sqlite/SQLiteQueryableMutableStore.java | 79 ++++++++ .../store/sqlite/SQLiteQueryableStore.java | 164 +++++++++++------ .../store/sqlite/SQLiteRetainStatement.java | 64 +++++++ .../java/sonia/scm/store/sqlite/Crewmate.java | 37 ++++ .../SQLiteQueryableMutableStoreTest.java | 174 ++++++++++++++++++ .../sqlite/SQLiteQueryableStoreTest.java | 28 +-- .../sonia/scm/store/sqlite/Spaceship.java | 21 +++ 12 files changed, 562 insertions(+), 83 deletions(-) create mode 100644 gradle/changelog/extend_sqlite_api.yaml create mode 100644 scm-persistence/src/main/java/sonia/scm/store/sqlite/SQLiteRetainStatement.java create mode 100644 scm-persistence/src/test/java/sonia/scm/store/sqlite/Crewmate.java diff --git a/docs/en/development/storage.md b/docs/en/development/storage.md index 46e9c72124..7c112daaf4 100644 --- a/docs/en/development/storage.md +++ b/docs/en/development/storage.md @@ -91,6 +91,22 @@ extends `QueryableStore` and `DataStore` to allow both queries and changes to st pure Queryable Store, it is mandatory to specify all parents to create a mutable store. This is needed so that new entities can be assigned to the correct parent(s). +Deleting objects in mutual stores can be done by either selecting all elements to be deleted with a query and +execute the `deleteAll()` function of the API or using a `retain(long keptElements)`. The latter is the recommended way +to implement FIFO *(first in, first out)* lists, which is especially intended for managing log entries. +Take this example: You are maintaining a display of when your spaceships received their maintenance. + +```java +spaceshipStore + .query(Spaceship.SPACESHIP_INSERVICE + .after(Instant.now().minus(5, ChronoUnit.DAYS))) + .orderBy(Order.DESC) + .retain(10); +``` + +With this code snippet, you will delete all alements older than five days and only keep 10 newest ones +matching this criterion. + ### Queryable Maintenance Store The `QueryableMaintenanceStore` is responsible for maintenance tasks, diff --git a/gradle/changelog/extend_sqlite_api.yaml b/gradle/changelog/extend_sqlite_api.yaml new file mode 100644 index 0000000000..c061f7857c --- /dev/null +++ b/gradle/changelog/extend_sqlite_api.yaml @@ -0,0 +1,2 @@ +- type: added + description: Delete and retain functionality for mutable queryable stores diff --git a/scm-core/src/main/java/sonia/scm/store/QueryableMutableStore.java b/scm-core/src/main/java/sonia/scm/store/QueryableMutableStore.java index ec3794c86e..3ec8c09900 100644 --- a/scm-core/src/main/java/sonia/scm/store/QueryableMutableStore.java +++ b/scm-core/src/main/java/sonia/scm/store/QueryableMutableStore.java @@ -34,6 +34,55 @@ import java.util.function.BooleanSupplier; public interface QueryableMutableStore extends DataStore, QueryableStore, AutoCloseable { void transactional(BooleanSupplier callback); + @Override + MutableQuery query(Condition... conditions); + @Override void close(); + + /** + * + * @param "Type" – type of the objects to query + * @param "Self" – specification of the {@link MutableQuery}. + */ + interface MutableQuery> extends Query { + /** + * Deletes all entries except the {@code keptElements} highest ones in terms of the provided order and query. + *

+ * For example, calling {@code retain(4)} after a query + * {@code store.query(CREATION_TIME.after(Instant.now().minus(5, DAYS)).orderBy(Order.DESC)} will remove every entry + * except those that + *
    + *
  • Match any conditions given by preceding queries (here: {@code store.query(...)} saying that only elements + * newer than five days may be kept), and
  • + *
  • Are among the 4 first ones in terms of the order given by the query result (here: a descending order with + * the newest being first).
  • + *
+ * This function is expected to only work in the realm of the {@link QueryableMutableStore}. For example, elements with + * other parent ids in some implementations are supposed to remain unaffected. + *

+ * Note: {@link #deleteAll()} is identical to {@code retain(0)}. + * @param keptElements Quantity of entities to be retained. + * @since 3.9.0 + */ + void retain(long keptElements); + + /** + * Deletes all entries matching the given query conditions. + *

+ * For example, calling {@code deleteAll()} after a query + * {@code store.query(CREATION_TIME.before(Instant.now().minus(5, DAYS))} will remove every entry with a + * {@code CREATION_TIME} property older than five days and keep those that don't match this condition (newer date). + *
+ * If no conditions have been selected beforehand, all entries in the realm of this {@link QueryableMutableStore} + * instance will be removed. It does not affect the structure of the store, and new entries may be added afterwards. + *

+ * This function is expected to only work in the realm of the {@link QueryableMutableStore}. For example, elements with + * other parent ids are supposed to remain unaffected. + *

+ * Note: Consider {@link #retain(long)} if you prefer to deliberately keep a given amounts of elements instead. + * @since 3.9.0 + */ + void deleteAll(); + } } diff --git a/scm-core/src/main/java/sonia/scm/store/QueryableStore.java b/scm-core/src/main/java/sonia/scm/store/QueryableStore.java index 8e2c9401a1..c2fe6dc4a1 100644 --- a/scm-core/src/main/java/sonia/scm/store/QueryableStore.java +++ b/scm-core/src/main/java/sonia/scm/store/QueryableStore.java @@ -50,7 +50,7 @@ public interface QueryableStore extends AutoCloseable { * @param conditions The conditions to filter the objects. * @return The query object to retrieve the result. */ - Query query(Condition... conditions); + Query query(Condition... conditions); /** * Used to specify the order of the result of a query. @@ -74,7 +74,7 @@ public interface QueryableStore extends AutoCloseable { * @param The type of the result objects (if a projection had been made, for example using * {@link #withIds()}). */ - interface Query { + interface Query> { /** * Returns the first found object, if the query returns at least one result. @@ -129,7 +129,7 @@ public interface QueryableStore extends AutoCloseable { * * @return The query object to continue building the query. */ - Query> withIds(); + Query, ?> withIds(); /** * Orders the result by the given field in the given order. If the order is not set, the order of the result is not @@ -139,7 +139,7 @@ public interface QueryableStore extends AutoCloseable { * @param order The order to use (either ascending or descending). * @return The query object to continue building the query. */ - Query orderBy(QueryField field, Order order); + SELF orderBy(QueryField field, Order order); /** * Returns the count of all objects that match the query. diff --git a/scm-persistence/src/main/java/sonia/scm/store/sqlite/SQLSelectStatement.java b/scm-persistence/src/main/java/sonia/scm/store/sqlite/SQLSelectStatement.java index f6b9249727..c5093f138a 100644 --- a/scm-persistence/src/main/java/sonia/scm/store/sqlite/SQLSelectStatement.java +++ b/scm-persistence/src/main/java/sonia/scm/store/sqlite/SQLSelectStatement.java @@ -33,6 +33,9 @@ class SQLSelectStatement extends ConditionalSQLStatement { SQLSelectStatement(List columns, SQLTable fromTable, List whereCondition, String orderBy, long limit, long offset) { super(whereCondition); + if (limit < 0 || offset < 0) { + throw new IllegalArgumentException("limit and offset must be non-negative"); + } this.columns = columns; this.fromTable = fromTable; this.orderBy = orderBy; diff --git a/scm-persistence/src/main/java/sonia/scm/store/sqlite/SQLiteQueryableMutableStore.java b/scm-persistence/src/main/java/sonia/scm/store/sqlite/SQLiteQueryableMutableStore.java index d9a8bbbc85..ffe59118d6 100644 --- a/scm-persistence/src/main/java/sonia/scm/store/sqlite/SQLiteQueryableMutableStore.java +++ b/scm-persistence/src/main/java/sonia/scm/store/sqlite/SQLiteQueryableMutableStore.java @@ -21,6 +21,7 @@ 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.Condition; import sonia.scm.store.IdHandlerForStores; import sonia.scm.store.QueryableMutableStore; import sonia.scm.store.StoreException; @@ -157,6 +158,11 @@ class SQLiteQueryableMutableStore extends SQLiteQueryableStore implements ); } + @Override + public MutableQuery query(Condition... conditions) { + return new SQLiteMutableQuery(clazz, conditions); + } + private String marshal(T item) { try { return objectMapper.writeValueAsString(item); @@ -170,4 +176,77 @@ class SQLiteQueryableMutableStore extends SQLiteQueryableStore implements conditions.add(new SQLCondition("=", new SQLField("ID"), new SQLValue(id))); return conditions; } + + private class SQLiteMutableQuery extends SQLiteQuery implements MutableQuery, Cloneable { + SQLiteMutableQuery(Class type, Condition[] conditions) { + super(type, conditions); + } + + @Override + public void deleteAll() { + List parentConditions = new ArrayList<>(); + evaluateParentConditions(parentConditions); + + SQLDeleteStatement sqlStatementQuery = + new SQLDeleteStatement( + computeFromTable(), + computeCondition() + ); + + executeWrite( + sqlStatementQuery, + statement -> { + statement.executeUpdate(); + return null; + } + ); + + log.debug("All entries for {} have been deleted.", SQLiteQueryableMutableStore.this); + } + + @Override + public void retain(long n) { + + List columns = new ArrayList<>(); + addParentIdSQLFields(columns); + + List conditions = new ArrayList<>(); + List parentConditions = new ArrayList<>(); + + evaluateParentConditions(parentConditions); + conditions.addAll(parentConditions); + conditions.addAll(this.computeCondition()); + + SQLSelectStatement selectStatement = new SQLSelectStatement( + columns, + computeFromTable(), + conditions, + getOrderByString(), + n, + 0L + ); + + SQLiteRetainStatement retainStatement = new SQLiteRetainStatement( + computeFromTable(), + columns, + selectStatement, + parentConditions + ); + + executeWrite( + retainStatement, + statement -> { + statement.executeUpdate(); + return null; + } + ); + + log.debug("All entries for {} have been deleted retaining the {} highest ones by ordering.", SQLiteQueryableMutableStore.this, n); + } + + @Override + public SQLiteMutableQuery clone() { + return (SQLiteMutableQuery) super.clone(); + } + } } diff --git a/scm-persistence/src/main/java/sonia/scm/store/sqlite/SQLiteQueryableStore.java b/scm-persistence/src/main/java/sonia/scm/store/sqlite/SQLiteQueryableStore.java index 924a138156..543dc4908a 100644 --- a/scm-persistence/src/main/java/sonia/scm/store/sqlite/SQLiteQueryableStore.java +++ b/scm-persistence/src/main/java/sonia/scm/store/sqlite/SQLiteQueryableStore.java @@ -18,6 +18,9 @@ package sonia.scm.store.sqlite; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.Setter; import lombok.extern.slf4j.Slf4j; import sonia.scm.plugin.QueryableTypeDescriptor; import sonia.scm.store.Condition; @@ -51,6 +54,7 @@ import java.util.function.BooleanSupplier; import java.util.function.Consumer; import java.util.stream.Stream; +import static java.lang.String.format; import static sonia.scm.store.sqlite.SQLiteIdentifiers.computeColumnIdentifier; import static sonia.scm.store.sqlite.SQLiteIdentifiers.computeTableName; @@ -82,7 +86,7 @@ class SQLiteQueryableStore implements QueryableStore, QueryableMaintenance } @Override - public Query query(Condition... conditions) { + public Query query(Condition... conditions) { return new SQLiteQuery<>(clazz, conditions); } @@ -269,14 +273,67 @@ class SQLiteQueryableStore implements QueryableStore, QueryableMaintenance return connection; } - private class SQLiteQuery implements Query { + void evaluateParentConditions(List 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); + } + } + + void addParentIdSQLFields(List fields) { + for (int i = 0; i < queryableTypeDescriptor.getTypes().length; i++) { + fields.add(new SQLField(computeColumnIdentifier(queryableTypeDescriptor.getTypes()[i]))); + } + fields.add(new SQLField("ID")); + } + + private String serialize(Object object) { + try { + return objectMapper.writeValueAsString(object); + } catch (JsonProcessingException e) { + throw new SerializationException("failed to serialize object to json", e); + } + } + + @Override + public String toString() { + return format("Store for class %s with parent ids %s", this.clazz.getName(), Arrays.toString(this.parentIds)); + } + + + interface StatementCallback { + R apply(PreparedStatement statement) throws SQLException, JsonProcessingException; + } + + private interface RowBuilder { + R build(String[] parentIds, String id, String json) throws JsonProcessingException; + } + + record OrderBy(QueryField field, Order order) { + @Override + public String toString() { + if (order == null) { + return field.getName(); + } else { + return field.getName() + " " + order; + } + } + } + + /** + * @param "Result" – result type + * @param "Self" – instance type of this query + */ + @Setter(AccessLevel.PACKAGE) + @Getter(AccessLevel.PROTECTED) + class SQLiteQuery> implements Query, Cloneable { private final Class resultType; private final Class entityType; private final Condition condition; - private final List> orderBy; + private List> orderBy; - private SQLiteQuery(Class resultType, Condition[] conditions) { + SQLiteQuery(Class resultType, Condition[] conditions) { this(resultType, resultType, conjunct(conditions), Collections.emptyList()); } @@ -288,6 +345,16 @@ class SQLiteQueryableStore implements QueryableStore, QueryableMaintenance this.orderBy = orderBy; } + private static Condition conjunct(Condition[] conditions) { + if (conditions.length == 0) { + return null; + } else if (conditions.length == 1) { + return conditions[0]; + } else { + return Conditions.and(conditions); + } + } + @Override public Optional findFirst() { return findAll(0, 1).stream().findFirst(); @@ -314,17 +381,14 @@ class SQLiteQueryableStore implements QueryableStore, QueryableMaintenance @Override public void forEach(Consumer consumer, long offset, long limit) { - StringBuilder orderByBuilder = new StringBuilder(); - if (orderBy != null && !orderBy.isEmpty()) { - toOrderBySQL(orderByBuilder); - } + String orderByString = getOrderByString(); SQLSelectStatement sqlSelectQuery = new SQLSelectStatement( computeFields(), computeFromTable(), computeCondition(), - orderByBuilder.toString(), + orderByString, limit, offset ); @@ -342,9 +406,17 @@ class SQLiteQueryableStore implements QueryableStore, QueryableMaintenance ); } + String getOrderByString() { + StringBuilder orderByBuilder = new StringBuilder(); + if (orderBy != null && !orderBy.isEmpty()) { + toOrderBySQL(orderByBuilder); + } + return orderByBuilder.toString(); + } + @Override @SuppressWarnings("unchecked") - public Query> withIds() { + public Query, ?> withIds() { return new SQLiteQuery<>((Class>) (Class) Result.class, resultType, condition, orderBy); } @@ -413,10 +485,12 @@ class SQLiteQueryableStore implements QueryableStore, QueryableMaintenance } @Override - public Query orderBy(QueryField field, Order order) { + public SELF orderBy(QueryField field, Order order) { List> extendedOrderBy = new ArrayList<>(this.orderBy); extendedOrderBy.add(new OrderBy<>(field, order)); - return new SQLiteQuery<>(resultType, entityType, condition, extendedOrderBy); + SELF newOrderBy = (SELF) this.clone(); + newOrderBy.setOrderBy(extendedOrderBy); + return newOrderBy; } private List computeFields() { @@ -428,20 +502,19 @@ class SQLiteQueryableStore implements QueryableStore, QueryableMaintenance return fields; } - private List computeCondition() { + List computeCondition() { List conditions = new ArrayList<>(); evaluateParentConditions(conditions); if (condition != null) { if (condition instanceof LeafCondition leafCondition) { - SQLCondition sqlCondition = SQLConditionMapper.mapToSQLCondition(leafCondition); - conditions.add(sqlCondition); + conditions.add(SQLConditionMapper.mapToSQLCondition(leafCondition)); } if (condition instanceof LogicalCondition logicalCondition) { - SQLLogicalCondition sqlLogicalCondition = SQLConditionMapper.mapToSQLLogicalCondition(logicalCondition); - conditions.add(sqlLogicalCondition); + conditions.add(SQLConditionMapper.mapToSQLLogicalCondition(logicalCondition)); } + log.debug("Unsupported condition type: {}", condition.getClass().getName()); } return conditions; @@ -467,16 +540,16 @@ class SQLiteQueryableStore implements QueryableStore, QueryableMaintenance private T_RESULT extractResult(ResultSet resultSet) throws JsonProcessingException, SQLException { T entity = objectMapper.readValue(resultSet.getString(1), entityType); if (resultType.isAssignableFrom(Result.class)) { - Map parentIds = new HashMap<>(queryableTypeDescriptor.getTypes().length); + Map parentIdMapping = new HashMap<>(queryableTypeDescriptor.getTypes().length); for (int i = 0; i < queryableTypeDescriptor.getTypes().length; i++) { - parentIds.put(computeColumnIdentifier(queryableTypeDescriptor.getTypes()[i]), resultSet.getString(i + 2)); + parentIdMapping.put(computeColumnIdentifier(queryableTypeDescriptor.getTypes()[i]), resultSet.getString(i + 2)); } String id = resultSet.getString(queryableTypeDescriptor.getTypes().length + 2); return (T_RESULT) new Result() { @Override public Optional getParentId(Class clazz) { String parentClassName = computeColumnIdentifier(clazz.getName()); - return Optional.ofNullable(parentIds.get(parentClassName)); + return Optional.ofNullable(parentIdMapping.get(parentClassName)); } @Override @@ -494,38 +567,21 @@ class SQLiteQueryableStore implements QueryableStore, QueryableMaintenance } } - private static Condition conjunct(Condition[] conditions) { - if (conditions.length == 0) { - return null; - } else if (conditions.length == 1) { - return conditions[0]; - } else { - return Conditions.and(conditions); + /* We explicitly suppress this warning since it's based on a generic whose information is lost during runtime, + which can be conveniently circumvented with clone(). + */ + @SuppressWarnings("java:S2975") + @Override + public SQLiteQuery clone() { + try { + // Keep in mind that this clone shares the mutable entities with its origin. + return (SQLiteQuery) super.clone(); + } catch (CloneNotSupportedException e) { + throw new AssertionError(); } } } - private void evaluateParentConditions(List 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 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 apply(PreparedStatement statement) throws SQLException, JsonProcessingException; - } - - record OrderBy(QueryField field, Order order) { - } - private class TemporaryTableMaintenanceIterator implements MaintenanceIterator { private final PreparedStatement iterateStatement; private final List columns; @@ -741,16 +797,4 @@ class SQLiteQueryableStore implements QueryableStore, QueryableMaintenance } } } - - 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 build(String[] parentIds, String id, String json) throws JsonProcessingException; - } } diff --git a/scm-persistence/src/main/java/sonia/scm/store/sqlite/SQLiteRetainStatement.java b/scm-persistence/src/main/java/sonia/scm/store/sqlite/SQLiteRetainStatement.java new file mode 100644 index 0000000000..e64fdc6d17 --- /dev/null +++ b/scm-persistence/src/main/java/sonia/scm/store/sqlite/SQLiteRetainStatement.java @@ -0,0 +1,64 @@ +/* + * 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; +import java.util.stream.Collectors; + +import static java.lang.String.format; + +class SQLiteRetainStatement implements SQLNodeWithValue { + + private final SQLTable table; + private final List columns; + private final SQLSelectStatement selectStatement; + private final List parentConditions; + + + SQLiteRetainStatement(SQLTable table, List columns, SQLSelectStatement selectStatement, List parentConditions) { + this.table = table; + this.columns = columns; + this.selectStatement = selectStatement; + this.parentConditions = parentConditions; + } + + @Override + public int apply(PreparedStatement statement, int index) throws SQLException { + index = selectStatement.apply(statement, index); + for (SQLNodeWithValue condition : parentConditions) { + index = condition.apply(statement, index); + } + return index; + } + + @Override + public String toSQL() { + String parentConditionStatement; + if (parentConditions == null || parentConditions.isEmpty()) { + parentConditionStatement = ""; + } else { + parentConditionStatement = "AND " + new SQLLogicalCondition("AND", parentConditions).toSQL(); + } + return format("DELETE FROM %s WHERE (%s) NOT IN (%s) %s", + table.toSQL(), + columns.stream().map(SQLField::toSQL).collect(Collectors.joining(",")), + selectStatement.toSQL(), + parentConditionStatement); + } +} diff --git a/scm-persistence/src/test/java/sonia/scm/store/sqlite/Crewmate.java b/scm-persistence/src/test/java/sonia/scm/store/sqlite/Crewmate.java new file mode 100644 index 0000000000..c625d14794 --- /dev/null +++ b/scm-persistence/src/test/java/sonia/scm/store/sqlite/Crewmate.java @@ -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.store.sqlite; + +import lombok.Data; +import lombok.NoArgsConstructor; +import sonia.scm.store.QueryableStore; +import sonia.scm.store.QueryableType; + +@Data +@QueryableType({Spaceship.class}) +@NoArgsConstructor +class Crewmate { + + static final QueryableStore.StringQueryField CREWMATE_ID = new QueryableStore.IdQueryField<>(); + Spaceship spaceship; + String name; + String description; + + Crewmate(Spaceship spaceship) { + this.spaceship = spaceship; + } +} diff --git a/scm-persistence/src/test/java/sonia/scm/store/sqlite/SQLiteQueryableMutableStoreTest.java b/scm-persistence/src/test/java/sonia/scm/store/sqlite/SQLiteQueryableMutableStoreTest.java index 6b28173caf..aeaa2ec3ee 100644 --- a/scm-persistence/src/test/java/sonia/scm/store/sqlite/SQLiteQueryableMutableStoreTest.java +++ b/scm-persistence/src/test/java/sonia/scm/store/sqlite/SQLiteQueryableMutableStoreTest.java @@ -21,6 +21,8 @@ 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.store.QueryableMutableStore; +import sonia.scm.store.QueryableStore; import sonia.scm.user.User; import java.nio.file.Path; @@ -28,9 +30,11 @@ import java.sql.Connection; import java.sql.DriverManager; import java.sql.ResultSet; import java.sql.SQLException; +import java.util.List; import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; @SuppressWarnings("resource") class SQLiteQueryableMutableStoreTest { @@ -309,4 +313,174 @@ class SQLiteQueryableMutableStoreTest { assertThat(store.getAll()).containsOnlyKeys("tricia"); } } + + @Nested + class DeleteAll { + @Test + void shouldDeleteAllInStoreWithoutSubsequentQuery() { + SQLiteQueryableMutableStore store = new StoreTestBuilder(connectionString).forClassWithIds(Spaceship.class); + store.put("1", new Spaceship("1")); + store.put("2", new Spaceship("2")); + store.put("3", new Spaceship("3")); + + store.query() + .orderBy(Spaceship.SPACESHIP_NAME, QueryableStore.Order.ASC) + .deleteAll(); + + assertThat(store.getAll()).isEmpty(); + } + + @Test + void shouldOnlyDeleteElementsMatchingTheQuery() { + SQLiteQueryableMutableStore store = new StoreTestBuilder(connectionString).forClassWithIds(Spaceship.class); + store.put("1", new Spaceship("1")); + store.put("2", new Spaceship("2")); + store.put("3", new Spaceship("3")); + + store.query(Spaceship.SPACESHIP_ID.in("1", "3")) + .orderBy(Spaceship.SPACESHIP_NAME, QueryableStore.Order.ASC) + .deleteAll(); + + assertThat(store.getAll()).hasSize(1); + assertThat(store.getAll()).containsOnlyKeys("2"); + } + + @Test + void shouldKeepEntriesFromOtherStore() { + StoreTestBuilder spaceshipStoreBuilder = new StoreTestBuilder(connectionString); + StoreTestBuilder crewmateStoreBuilder = new StoreTestBuilder(connectionString, "Spaceship"); + try ( + SQLiteQueryableMutableStore spaceshipStore = spaceshipStoreBuilder.forClassWithIds(Spaceship.class); + SQLiteQueryableMutableStore crewmateStoreForShipOne = crewmateStoreBuilder.forClassWithIds(Crewmate.class, "1"); + SQLiteQueryableMutableStore crewmateStoreForShipTwo = crewmateStoreBuilder.forClassWithIds(Crewmate.class, "2") + ) { + Spaceship spaceshipOne = new Spaceship("1"); + Spaceship spaceshipTwo = new Spaceship("2"); + spaceshipStore.put(spaceshipOne); + spaceshipStore.put(spaceshipTwo); + + crewmateStoreForShipOne.put("1", new Crewmate(spaceshipOne)); + crewmateStoreForShipOne.put("2", new Crewmate(spaceshipOne)); + crewmateStoreForShipTwo.put("1", new Crewmate(spaceshipTwo)); + + crewmateStoreForShipOne.query().deleteAll(); + + assertThat(crewmateStoreForShipOne.getAll()).isEmpty(); + assertThat(crewmateStoreForShipTwo.getAll()).hasSize(1); + } + } + } + + @Nested + class Retain { + @Test + void shouldRetainOneWithAscendingOrder() { + SQLiteQueryableMutableStore store = new StoreTestBuilder(connectionString).forClassWithIds(Spaceship.class); + store.put("1", new Spaceship("1")); + store.put("2", new Spaceship("2")); + store.put("3", new Spaceship("3")); + + store.query() + .orderBy(Spaceship.SPACESHIP_NAME, QueryableStore.Order.ASC) + .retain(1); + + assertThat(store.getAll()).hasSize(1); + assertThat(store.get("1")).isNotNull(); + } + + @Test + void shouldThrowIllegalArgumentExceptionIfKeptElementsIsNegative() { + SQLiteQueryableMutableStore store = new StoreTestBuilder(connectionString).forClassWithIds(Spaceship.class); + store.put("1", new Spaceship("1")); + store.put("2", new Spaceship("2")); + store.put("3", new Spaceship("3")); + + QueryableMutableStore.MutableQuery mutableQuery = store.query() + .orderBy(Spaceship.SPACESHIP_NAME, QueryableStore.Order.ASC); + + assertThatThrownBy(() -> mutableQuery.retain(-1)).isInstanceOf(IllegalArgumentException.class); + } + + @Test + void shouldRetainOneWithDescendingOrder() { + SQLiteQueryableMutableStore store = new StoreTestBuilder(connectionString).forClassWithIds(Spaceship.class); + store.put("1", new Spaceship("1")); + store.put("2", new Spaceship("2")); + store.put("3", new Spaceship("3")); + + store.query() + .orderBy(Spaceship.SPACESHIP_NAME, QueryableStore.Order.DESC) + .retain(1); + + assertThat(store.getAll()).hasSize(1); + assertThat(store.get("3")).isNotNull(); + } + + @Test + void shouldDeleteUnselectedEntitiesAndRetainKeptElementsFromTheSelectedOnes() { + SQLiteQueryableMutableStore store = new StoreTestBuilder(connectionString).forClassWithIds(Spaceship.class); + + Spaceship spaceshipOne = new Spaceship("LazyShip"); + Spaceship spaceshipTwo = new Spaceship("Biblical Ship"); + Spaceship spaceshipThree = new Spaceship("Millennium"); + + spaceshipOne.crew = List.of("Foxtrot", "Icebear", "Possum"); + spaceshipTwo.crew = List.of("Adam", "Eva", "Gabriel", "Lilith", "Michael"); + spaceshipThree.crew = List.of("Chewbacca", "R2-D2", "C3PO", "Han Solo", "Luke Skywalker", "Obi-Wan Kenobi"); + + store.put("LazyShip", spaceshipOne); + store.put("Biblical Ship", spaceshipTwo); + store.put("Millennium", spaceshipThree); + + store.query(Spaceship.SPACESHIP_CREW_SIZE.greater(3L)) + .orderBy(Spaceship.SPACESHIP_CREW_SIZE, QueryableStore.Order.DESC) + .retain(1); + + assertThat(store.getAll()).hasSize(1); + assertThat(store.get("LazyShip")).isNull(); + assertThat(store.get("Biblical Ship")).isNull(); + assertThat(store.get("Millennium")).isNotNull(); + } + + @Test + void shouldRetainEverythingIfKeptElementsHigherThanContentQuantity() { + SQLiteQueryableMutableStore store = new StoreTestBuilder(connectionString).forClassWithIds(Spaceship.class); + store.put("1", new Spaceship("1")); + store.put("2", new Spaceship("2")); + store.put("3", new Spaceship("3")); + + store.query() + .orderBy(Spaceship.SPACESHIP_NAME, QueryableStore.Order.DESC) + .retain(5); + + assertThat(store.getAll()).hasSize(3); + } + + @Test + void shouldKeepEntriesFromOtherStore() { + StoreTestBuilder spaceshipStoreBuilder = new StoreTestBuilder(connectionString); + StoreTestBuilder crewmateStoreBuilder = new StoreTestBuilder(connectionString, "Spaceship"); + try ( + SQLiteQueryableMutableStore spaceshipStore = spaceshipStoreBuilder.forClassWithIds(Spaceship.class); + SQLiteQueryableMutableStore crewmateStoreForShipOne = crewmateStoreBuilder.forClassWithIds(Crewmate.class, "1"); + SQLiteQueryableMutableStore crewmateStoreForShipTwo = crewmateStoreBuilder.forClassWithIds(Crewmate.class, "2") + ) { + Spaceship spaceshipOne = new Spaceship("1"); + Spaceship spaceshipTwo = new Spaceship("2"); + spaceshipStore.put(spaceshipOne); + spaceshipStore.put(spaceshipTwo); + + crewmateStoreForShipOne.put("1", new Crewmate(spaceshipOne)); + crewmateStoreForShipOne.put("2", new Crewmate(spaceshipOne)); + crewmateStoreForShipTwo.put("1", new Crewmate(spaceshipTwo)); + crewmateStoreForShipTwo.put("2", new Crewmate(spaceshipTwo)); + + crewmateStoreForShipOne.query().retain(1); + + assertThat(crewmateStoreForShipOne.getAll()).hasSize(1); + assertThat(crewmateStoreForShipTwo.getAll()).hasSize(2); + } + } + + } } diff --git a/scm-persistence/src/test/java/sonia/scm/store/sqlite/SQLiteQueryableStoreTest.java b/scm-persistence/src/test/java/sonia/scm/store/sqlite/SQLiteQueryableStoreTest.java index 02416ef532..2154783cd1 100644 --- a/scm-persistence/src/test/java/sonia/scm/store/sqlite/SQLiteQueryableStoreTest.java +++ b/scm-persistence/src/test/java/sonia/scm/store/sqlite/SQLiteQueryableStoreTest.java @@ -40,6 +40,15 @@ import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static sonia.scm.store.sqlite.Spaceship.SPACESHIP_CREW; +import static sonia.scm.store.sqlite.Spaceship.SPACESHIP_CREW_SIZE; +import static sonia.scm.store.sqlite.Spaceship.SPACESHIP_DESTINATIONS; +import static sonia.scm.store.sqlite.Spaceship.SPACESHIP_DESTINATIONS_SIZE; +import static sonia.scm.store.sqlite.Spaceship.SPACESHIP_FLIGHT_COUNT; +import static sonia.scm.store.sqlite.Spaceship.SPACESHIP_ID; +import static sonia.scm.store.sqlite.Spaceship.SPACESHIP_INSERVICE; +import static sonia.scm.store.sqlite.Spaceship.SPACESHIP_NAME; +import static sonia.scm.store.sqlite.Spaceship.SPACESHIP_RANGE; @SuppressWarnings({"resource", "unchecked"}) class SQLiteQueryableStoreTest { @@ -1036,23 +1045,4 @@ class SQLiteQueryableStoreTest { enum Range { SOLAR_SYSTEM, INNER_GALACTIC, INTER_GALACTIC } - - private static final QueryableStore.StringQueryField SPACESHIP_ID = - new QueryableStore.IdQueryField<>(); - private static final QueryableStore.StringQueryField SPACESHIP_NAME = - new QueryableStore.StringQueryField<>("name"); - private static final QueryableStore.EnumQueryField SPACESHIP_RANGE = - new QueryableStore.EnumQueryField<>("range"); - private static final QueryableStore.CollectionQueryField SPACESHIP_CREW = - new QueryableStore.CollectionQueryField<>("crew"); - private static final QueryableStore.CollectionSizeQueryField SPACESHIP_CREW_SIZE = - new QueryableStore.CollectionSizeQueryField<>("crew"); - private static final QueryableStore.MapQueryField SPACESHIP_DESTINATIONS = - new QueryableStore.MapQueryField<>("destinations"); - private static final QueryableStore.MapSizeQueryField SPACESHIP_DESTINATIONS_SIZE = - new QueryableStore.MapSizeQueryField<>("destinations"); - private static final QueryableStore.InstantQueryField SPACESHIP_INSERVICE = - new QueryableStore.InstantQueryField<>("inServiceSince"); - private static final QueryableStore.IntegerQueryField SPACESHIP_FLIGHT_COUNT = - new QueryableStore.IntegerQueryField<>("flightCount"); } diff --git a/scm-persistence/src/test/java/sonia/scm/store/sqlite/Spaceship.java b/scm-persistence/src/test/java/sonia/scm/store/sqlite/Spaceship.java index 8356e24362..60f43c1bec 100644 --- a/scm-persistence/src/test/java/sonia/scm/store/sqlite/Spaceship.java +++ b/scm-persistence/src/test/java/sonia/scm/store/sqlite/Spaceship.java @@ -20,6 +20,7 @@ import jakarta.xml.bind.annotation.XmlAccessType; import jakarta.xml.bind.annotation.XmlAccessorType; import jakarta.xml.bind.annotation.XmlRootElement; import lombok.EqualsAndHashCode; +import sonia.scm.store.QueryableStore; import sonia.scm.store.QueryableType; import java.time.Instant; @@ -32,6 +33,26 @@ import java.util.Map; @QueryableType @EqualsAndHashCode class Spaceship { + + static final QueryableStore.StringQueryField SPACESHIP_ID = + new QueryableStore.IdQueryField<>(); + static final QueryableStore.StringQueryField SPACESHIP_NAME = + new QueryableStore.StringQueryField<>("name"); + static final QueryableStore.EnumQueryField SPACESHIP_RANGE = + new QueryableStore.EnumQueryField<>("range"); + static final QueryableStore.CollectionQueryField SPACESHIP_CREW = + new QueryableStore.CollectionQueryField<>("crew"); + static final QueryableStore.CollectionSizeQueryField SPACESHIP_CREW_SIZE = + new QueryableStore.CollectionSizeQueryField<>("crew"); + static final QueryableStore.MapQueryField SPACESHIP_DESTINATIONS = + new QueryableStore.MapQueryField<>("destinations"); + static final QueryableStore.MapSizeQueryField SPACESHIP_DESTINATIONS_SIZE = + new QueryableStore.MapSizeQueryField<>("destinations"); + static final QueryableStore.InstantQueryField SPACESHIP_INSERVICE = + new QueryableStore.InstantQueryField<>("inServiceSince"); + static final QueryableStore.IntegerQueryField SPACESHIP_FLIGHT_COUNT = + new QueryableStore.IntegerQueryField<>("flightCount"); + String name; SQLiteQueryableStoreTest.Range range; Collection crew;