Browse Source

Introduced pessimistic locks for derived queries.

Methods which use the derive query functionality now can be annotated with `@Lock` to used a given `LockMode`. Right now there are two different modes `PESSIMISTIC_READ` and `PESSIMISTIC_WRITE`. Based on the dialect the right select is generated. For example for HSQLDB `Select ... FOR UPDATE`.

See #1041
Original pull request #1158
pull/1166/head
Diego Krupitza 4 years ago committed by Jens Schauder
parent
commit
e68c3557c0
No known key found for this signature in database
GPG Key ID: 45CC872F17423DBF
  1. 9
      spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/JdbcCountQueryCreator.java
  2. 15
      spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/JdbcQueryCreator.java
  3. 20
      spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/JdbcQueryMethod.java
  4. 39
      spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/Lock.java
  5. 7
      spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/PartTreeJdbcQuery.java
  6. 16
      spring-data-jdbc/src/test/java/org/springframework/data/jdbc/repository/JdbcRepositoryIntegrationTests.java
  7. 35
      spring-data-jdbc/src/test/java/org/springframework/data/jdbc/repository/query/JdbcQueryMethodUnitTests.java
  8. 62
      spring-data-jdbc/src/test/java/org/springframework/data/jdbc/repository/query/PartTreeJdbcQueryUnitTests.java

9
spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/JdbcCountQueryCreator.java

@ -1,5 +1,5 @@ @@ -1,5 +1,5 @@
/*
* Copyright 2021 the original author or authors.
* Copyright 2021-2022 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -30,18 +30,21 @@ import org.springframework.data.relational.repository.query.RelationalParameterA @@ -30,18 +30,21 @@ import org.springframework.data.relational.repository.query.RelationalParameterA
import org.springframework.data.repository.query.ReturnedType;
import org.springframework.data.repository.query.parser.PartTree;
import java.util.Optional;
/**
* {@link JdbcQueryCreator} that creates {@code COUNT(*)} queries without applying limit/offset and {@link Sort}.
*
* @author Mark Paluch
* @author Diego Krupitza
* @since 2.2
*/
class JdbcCountQueryCreator extends JdbcQueryCreator {
JdbcCountQueryCreator(RelationalMappingContext context, PartTree tree, JdbcConverter converter, Dialect dialect,
RelationalEntityMetadata<?> entityMetadata, RelationalParameterAccessor accessor, boolean isSliceQuery,
ReturnedType returnedType) {
super(context, tree, converter, dialect, entityMetadata, accessor, isSliceQuery, returnedType);
ReturnedType returnedType, Optional<Lock> lockMode) {
super(context, tree, converter, dialect, entityMetadata, accessor, isSliceQuery, returnedType, lockMode);
}
@Override

15
spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/JdbcQueryCreator.java

@ -1,5 +1,5 @@ @@ -1,5 +1,5 @@
/*
* Copyright 2020-2021 the original author or authors.
* Copyright 2020-2022 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -18,6 +18,7 @@ package org.springframework.data.jdbc.repository.query; @@ -18,6 +18,7 @@ package org.springframework.data.jdbc.repository.query;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
@ -57,6 +58,7 @@ import org.springframework.util.Assert; @@ -57,6 +58,7 @@ import org.springframework.util.Assert;
* @author Mark Paluch
* @author Jens Schauder
* @author Myeonghyeon Lee
* @author Diego Krupitza
* @since 2.0
*/
class JdbcQueryCreator extends RelationalQueryCreator<ParametrizedQuery> {
@ -69,6 +71,7 @@ class JdbcQueryCreator extends RelationalQueryCreator<ParametrizedQuery> { @@ -69,6 +71,7 @@ class JdbcQueryCreator extends RelationalQueryCreator<ParametrizedQuery> {
private final RenderContextFactory renderContextFactory;
private final boolean isSliceQuery;
private final ReturnedType returnedType;
private final Optional<Lock> lockMode;
/**
* Creates new instance of this class with the given {@link PartTree}, {@link JdbcConverter}, {@link Dialect},
@ -85,7 +88,7 @@ class JdbcQueryCreator extends RelationalQueryCreator<ParametrizedQuery> { @@ -85,7 +88,7 @@ class JdbcQueryCreator extends RelationalQueryCreator<ParametrizedQuery> {
*/
JdbcQueryCreator(RelationalMappingContext context, PartTree tree, JdbcConverter converter, Dialect dialect,
RelationalEntityMetadata<?> entityMetadata, RelationalParameterAccessor accessor, boolean isSliceQuery,
ReturnedType returnedType) {
ReturnedType returnedType, Optional<Lock> lockMode) {
super(tree, accessor);
Assert.notNull(converter, "JdbcConverter must not be null");
@ -102,6 +105,7 @@ class JdbcQueryCreator extends RelationalQueryCreator<ParametrizedQuery> { @@ -102,6 +105,7 @@ class JdbcQueryCreator extends RelationalQueryCreator<ParametrizedQuery> {
this.renderContextFactory = new RenderContextFactory(dialect);
this.isSliceQuery = isSliceQuery;
this.returnedType = returnedType;
this.lockMode = lockMode;
}
/**
@ -168,7 +172,12 @@ class JdbcQueryCreator extends RelationalQueryCreator<ParametrizedQuery> { @@ -168,7 +172,12 @@ class JdbcQueryCreator extends RelationalQueryCreator<ParametrizedQuery> {
whereBuilder);
selectOrderBuilder = applyOrderBy(sort, entity, table, selectOrderBuilder);
Select select = selectOrderBuilder.build();
SelectBuilder.BuildSelect completedBuildSelect = selectOrderBuilder;
if (this.lockMode.isPresent()) {
completedBuildSelect = selectOrderBuilder.lock(this.lockMode.get().value());
}
Select select = completedBuildSelect.build();
String sql = SqlRenderer.create(renderContextFactory.createRenderContext()).render(select);

20
spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/JdbcQueryMethod.java

@ -1,5 +1,5 @@ @@ -1,5 +1,5 @@
/*
* Copyright 2020-2021 the original author or authors.
* Copyright 2020-2022 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -47,6 +47,7 @@ import org.springframework.util.StringUtils; @@ -47,6 +47,7 @@ import org.springframework.util.StringUtils;
* @author Kazuki Shimizu
* @author Moises Cisneros
* @author Hebert Coelho
* @author Diego Krupitza
*/
public class JdbcQueryMethod extends QueryMethod {
@ -168,7 +169,6 @@ public class JdbcQueryMethod extends QueryMethod { @@ -168,7 +169,6 @@ public class JdbcQueryMethod extends QueryMethod {
return StringUtils.hasText(annotatedName) ? annotatedName : super.getNamedQueryName();
}
/**
* Returns the class to be used as {@link org.springframework.jdbc.core.RowMapper}
*
@ -245,6 +245,22 @@ public class JdbcQueryMethod extends QueryMethod { @@ -245,6 +245,22 @@ public class JdbcQueryMethod extends QueryMethod {
return doFindAnnotation(Query.class);
}
/**
* @return is a {@link Lock} annotation present or not.
*/
public boolean hasLockMode() {
return lookupLockAnnotation().isPresent();
}
/**
* Looks up the {@link Lock} annotation from the query method.
*
* @return the {@link Optional} wrapped {@link Lock} annotation.
*/
Optional<Lock> lookupLockAnnotation() {
return doFindAnnotation(Lock.class);
}
@SuppressWarnings("unchecked")
private <A extends Annotation> Optional<A> doFindAnnotation(Class<A> annotationType) {

39
spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/Lock.java

@ -0,0 +1,39 @@ @@ -0,0 +1,39 @@
/*
* Copyright 2022 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.data.jdbc.repository.query;
import org.springframework.data.annotation.QueryAnnotation;
import org.springframework.data.relational.core.sql.LockMode;
import java.lang.annotation.*;
/**
* Annotation to provide a lock mode for a given query.
*
* @author Diego Krupitza
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@QueryAnnotation
@Documented
public @interface Lock {
/**
* Defines which type of {@link LockMode} we want to use.
*/
LockMode value() default LockMode.PESSIMISTIC_READ;
}

7
spring-data-jdbc/src/main/java/org/springframework/data/jdbc/repository/query/PartTreeJdbcQuery.java

@ -1,5 +1,5 @@ @@ -1,5 +1,5 @@
/*
* Copyright 2020-2021 the original author or authors.
* Copyright 2020-2022 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -50,6 +50,7 @@ import org.springframework.util.Assert; @@ -50,6 +50,7 @@ import org.springframework.util.Assert;
*
* @author Mark Paluch
* @author Jens Schauder
* @author Diego Krupitza
* @since 2.0
*/
public class PartTreeJdbcQuery extends AbstractJdbcQuery {
@ -163,7 +164,7 @@ public class PartTreeJdbcQuery extends AbstractJdbcQuery { @@ -163,7 +164,7 @@ public class PartTreeJdbcQuery extends AbstractJdbcQuery {
RelationalEntityMetadata<?> entityMetadata = getQueryMethod().getEntityInformation();
JdbcCountQueryCreator queryCreator = new JdbcCountQueryCreator(context, tree, converter, dialect,
entityMetadata, accessor, false, processor.getReturnedType());
entityMetadata, accessor, false, processor.getReturnedType(), getQueryMethod().lookupLockAnnotation());
ParametrizedQuery countQuery = queryCreator.createQuery(Sort.unsorted());
Object count = singleObjectQuery((rs, i) -> rs.getLong(1)).execute(countQuery.getQuery(),
@ -181,7 +182,7 @@ public class PartTreeJdbcQuery extends AbstractJdbcQuery { @@ -181,7 +182,7 @@ public class PartTreeJdbcQuery extends AbstractJdbcQuery {
RelationalEntityMetadata<?> entityMetadata = getQueryMethod().getEntityInformation();
JdbcQueryCreator queryCreator = new JdbcQueryCreator(context, tree, converter, dialect, entityMetadata, accessor,
getQueryMethod().isSliceQuery(), returnedType);
getQueryMethod().isSliceQuery(), returnedType, this.getQueryMethod().lookupLockAnnotation());
return queryCreator.createQuery(getDynamicSort(accessor));
}

16
spring-data-jdbc/src/test/java/org/springframework/data/jdbc/repository/JdbcRepositoryIntegrationTests.java

@ -1,5 +1,5 @@ @@ -1,5 +1,5 @@
/*
* Copyright 2017-2021 the original author or authors.
* Copyright 2017-2022 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -51,6 +51,7 @@ import org.springframework.data.domain.PageRequest; @@ -51,6 +51,7 @@ import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Slice;
import org.springframework.data.jdbc.core.mapping.AggregateReference;
import org.springframework.data.jdbc.repository.query.Lock;
import org.springframework.data.jdbc.repository.query.Modifying;
import org.springframework.data.jdbc.repository.query.Query;
import org.springframework.data.jdbc.repository.support.JdbcRepositoryFactory;
@ -61,6 +62,7 @@ import org.springframework.data.jdbc.testing.TestDatabaseFeatures; @@ -61,6 +62,7 @@ import org.springframework.data.jdbc.testing.TestDatabaseFeatures;
import org.springframework.data.relational.core.mapping.event.AbstractRelationalEvent;
import org.springframework.data.relational.core.mapping.event.AfterConvertEvent;
import org.springframework.data.relational.core.mapping.event.AfterLoadEvent;
import org.springframework.data.relational.core.sql.LockMode;
import org.springframework.data.repository.CrudRepository;
import org.springframework.data.repository.core.NamedQueries;
import org.springframework.data.repository.core.support.PropertiesBasedNamedQueries;
@ -331,6 +333,13 @@ public class JdbcRepositoryIntegrationTests { @@ -331,6 +333,13 @@ public class JdbcRepositoryIntegrationTests {
assertThat(repository.findAllByNamedQuery()).hasSize(1);
}
@Test
void findAllByFirstnameWithLock() {
DummyEntity dummyEntity = createDummyEntity();
repository.save(dummyEntity);
assertThat(repository.findAllByName(dummyEntity.getName())).hasSize(1);
}
@Test // GH-1022
public void findAllByCustomQueryName() {
@ -574,7 +583,11 @@ public class JdbcRepositoryIntegrationTests { @@ -574,7 +583,11 @@ public class JdbcRepositoryIntegrationTests {
interface DummyEntityRepository extends CrudRepository<DummyEntity, Long> {
@Lock(LockMode.PESSIMISTIC_WRITE)
List<DummyEntity> findAllByName(String name);
List<DummyEntity> findAllByNamedQuery();
@Query(name = "DummyEntity.customQuery")
List<DummyEntity> findAllByCustomNamedQuery();
@ -624,6 +637,7 @@ public class JdbcRepositoryIntegrationTests { @@ -624,6 +637,7 @@ public class JdbcRepositoryIntegrationTests {
List<DummyEntity> findByFlagTrue();
List<DummyEntity> findByRef(int ref);
List<DummyEntity> findByRef(AggregateReference<DummyEntity, Long> ref);
}

35
spring-data-jdbc/src/test/java/org/springframework/data/jdbc/repository/query/JdbcQueryMethodUnitTests.java

@ -1,5 +1,5 @@ @@ -1,5 +1,5 @@
/*
* Copyright 2020-2021 the original author or authors.
* Copyright 2020-2022 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -28,6 +28,7 @@ import org.junit.jupiter.api.Test; @@ -28,6 +28,7 @@ import org.junit.jupiter.api.Test;
import org.springframework.data.jdbc.core.mapping.JdbcMappingContext;
import org.springframework.data.projection.ProjectionFactory;
import org.springframework.data.relational.core.sql.LockMode;
import org.springframework.data.repository.core.NamedQueries;
import org.springframework.data.repository.core.RepositoryMetadata;
import org.springframework.data.repository.core.support.PropertiesBasedNamedQueries;
@ -41,6 +42,7 @@ import org.springframework.jdbc.core.RowMapper; @@ -41,6 +42,7 @@ import org.springframework.jdbc.core.RowMapper;
* @author Oliver Gierke
* @author Moises Cisneros
* @author Mark Paluch
* @author Diego Krupitza
*/
public class JdbcQueryMethodUnitTests {
@ -120,6 +122,37 @@ public class JdbcQueryMethodUnitTests { @@ -120,6 +122,37 @@ public class JdbcQueryMethodUnitTests {
assertThat(queryMethod.getDeclaredQuery()).isEqualTo(null);
}
@Test // GH-1041
void returnsQueryMethodWithLock() throws NoSuchMethodException {
JdbcQueryMethod queryMethodWithWriteLock = createJdbcQueryMethod("queryMethodWithWriteLock");
JdbcQueryMethod queryMethodWithReadLock = createJdbcQueryMethod("queryMethodWithReadLock");
assertThat(queryMethodWithWriteLock.hasLockMode()).isTrue();
assertThat(queryMethodWithReadLock.hasLockMode()).isTrue();
}
@Test // GH-1041
void returnsQueryMethodWithCorrectLockType() throws NoSuchMethodException {
JdbcQueryMethod queryMethodWithWriteLock = createJdbcQueryMethod("queryMethodWithWriteLock");
JdbcQueryMethod queryMethodWithReadLock = createJdbcQueryMethod("queryMethodWithReadLock");
assertThat(queryMethodWithWriteLock.lookupLockAnnotation()).isPresent();
assertThat(queryMethodWithReadLock.lookupLockAnnotation()).isPresent();
assertThat(queryMethodWithWriteLock.lookupLockAnnotation().get().value()).isEqualTo(LockMode.PESSIMISTIC_WRITE);
assertThat(queryMethodWithReadLock.lookupLockAnnotation().get().value()).isEqualTo(LockMode.PESSIMISTIC_READ);
}
@Lock(LockMode.PESSIMISTIC_WRITE)
@Query
private void queryMethodWithWriteLock() {}
@Lock(LockMode.PESSIMISTIC_READ)
@Query
private void queryMethodWithReadLock() {}
@Query(value = QUERY, rowMapperClass = CustomRowMapper.class)
private void queryMethod() {}

62
spring-data-jdbc/src/test/java/org/springframework/data/jdbc/repository/query/PartTreeJdbcQueryUnitTests.java

@ -1,5 +1,5 @@ @@ -1,5 +1,5 @@
/*
* Copyright 2020-2021 the original author or authors.
* Copyright 2020-2022 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -42,6 +42,8 @@ import org.springframework.data.relational.core.dialect.H2Dialect; @@ -42,6 +42,8 @@ import org.springframework.data.relational.core.dialect.H2Dialect;
import org.springframework.data.relational.core.mapping.Embedded;
import org.springframework.data.relational.core.mapping.MappedCollection;
import org.springframework.data.relational.core.mapping.Table;
import org.springframework.data.relational.core.sql.In;
import org.springframework.data.relational.core.sql.LockMode;
import org.springframework.data.relational.repository.query.RelationalParametersParameterAccessor;
import org.springframework.data.repository.NoRepositoryBean;
import org.springframework.data.repository.Repository;
@ -58,6 +60,7 @@ import org.springframework.jdbc.core.namedparam.NamedParameterJdbcOperations; @@ -58,6 +60,7 @@ import org.springframework.jdbc.core.namedparam.NamedParameterJdbcOperations;
* @author Mark Paluch
* @author Jens Schauder
* @author Myeonghyeon Lee
* @author Diego Krupitza
*/
@ExtendWith(MockitoExtension.class)
public class PartTreeJdbcQueryUnitTests {
@ -85,7 +88,7 @@ public class PartTreeJdbcQueryUnitTests { @@ -85,7 +88,7 @@ public class PartTreeJdbcQueryUnitTests {
PartTreeJdbcQuery jdbcQuery = createQuery(queryMethod);
Hobby hobby = new Hobby();
hobby.name = "twentythree";
ParametrizedQuery query = jdbcQuery.createQuery(getAccessor(queryMethod, new Object[] {hobby}), returnedType);
ParametrizedQuery query = jdbcQuery.createQuery(getAccessor(queryMethod, new Object[] { hobby }), returnedType);
assertSoftly(softly -> {
@ -96,6 +99,47 @@ public class PartTreeJdbcQueryUnitTests { @@ -96,6 +99,47 @@ public class PartTreeJdbcQueryUnitTests {
});
}
@Test // GH-922
void createQueryWithPessimisticWriteLock() throws Exception {
JdbcQueryMethod queryMethod = getQueryMethod("findAllByFirstNameAndLastName", String.class, String.class);
PartTreeJdbcQuery jdbcQuery = createQuery(queryMethod);
String firstname = "Diego";
String lastname = "Krupitza";
ParametrizedQuery query = jdbcQuery.createQuery(getAccessor(queryMethod, new Object[] { firstname, lastname }),
returnedType);
assertSoftly(softly -> {
softly.assertThat(query.getQuery().toUpperCase()).endsWith("FOR UPDATE");
softly.assertThat(query.getParameterSource().getValue("first_name")).isEqualTo(firstname);
softly.assertThat(query.getParameterSource().getValue("last_name")).isEqualTo(lastname);
});
}
@Test // GH-922
void createQueryWithPessimisticReadLock() throws Exception {
JdbcQueryMethod queryMethod = getQueryMethod("findAllByFirstNameAndAge", String.class, Integer.class);
PartTreeJdbcQuery jdbcQuery = createQuery(queryMethod);
String firstname = "Diego";
Integer age = 22;
ParametrizedQuery query = jdbcQuery.createQuery(getAccessor(queryMethod, new Object[] { firstname, age }),
returnedType);
assertSoftly(softly -> {
// this is also for update since h2 dialect does not distinguish between lockmodes
softly.assertThat(query.getQuery().toUpperCase()).endsWith("FOR UPDATE");
softly.assertThat(query.getParameterSource().getValue("first_name")).isEqualTo(firstname);
softly.assertThat(query.getParameterSource().getValue("age")).isEqualTo(age);
});
}
@Test // DATAJDBC-318
public void shouldFailForQueryByList() throws Exception {
@ -116,7 +160,7 @@ public class PartTreeJdbcQueryUnitTests { @@ -116,7 +160,7 @@ public class PartTreeJdbcQueryUnitTests {
JdbcQueryMethod queryMethod = getQueryMethod("findViaReferenceByHobbyReference", AggregateReference.class);
PartTreeJdbcQuery jdbcQuery = createQuery(queryMethod);
AggregateReference<Object, String> hobby = AggregateReference.to("twentythree");
ParametrizedQuery query = jdbcQuery.createQuery(getAccessor(queryMethod, new Object[] {hobby}), returnedType);
ParametrizedQuery query = jdbcQuery.createQuery(getAccessor(queryMethod, new Object[] { hobby }), returnedType);
assertSoftly(softly -> {
@ -133,7 +177,7 @@ public class PartTreeJdbcQueryUnitTests { @@ -133,7 +177,7 @@ public class PartTreeJdbcQueryUnitTests {
JdbcQueryMethod queryMethod = getQueryMethod("findViaIdByHobbyReference", String.class);
PartTreeJdbcQuery jdbcQuery = createQuery(queryMethod);
String hobby = "twentythree";
ParametrizedQuery query = jdbcQuery.createQuery(getAccessor(queryMethod, new Object[] {hobby}), returnedType);
ParametrizedQuery query = jdbcQuery.createQuery(getAccessor(queryMethod, new Object[] { hobby }), returnedType);
assertSoftly(softly -> {
@ -213,7 +257,6 @@ public class PartTreeJdbcQueryUnitTests { @@ -213,7 +257,6 @@ public class PartTreeJdbcQueryUnitTests {
+ ".\"FIRST_NAME\" = :first_name)");
}
@Test // DATAJDBC-318
public void createsQueryToFindAllEntitiesByDateAttributeBetween() throws Exception {
@ -644,6 +687,12 @@ public class PartTreeJdbcQueryUnitTests { @@ -644,6 +687,12 @@ public class PartTreeJdbcQueryUnitTests {
@NoRepositoryBean
interface UserRepository extends Repository<User, Long> {
@Lock(LockMode.PESSIMISTIC_WRITE)
List<User> findAllByFirstNameAndLastName(String firstName, String lastName);
@Lock(LockMode.PESSIMISTIC_READ)
List<User> findAllByFirstNameAndAge(String firstName, Integer age);
List<User> findAllByFirstName(String firstName);
List<User> findAllByHated(Hobby hobby);
@ -758,7 +807,6 @@ public class PartTreeJdbcQueryUnitTests { @@ -758,7 +807,6 @@ public class PartTreeJdbcQueryUnitTests {
}
static class Hobby {
@Id
String name;
@Id String name;
}
}

Loading…
Cancel
Save