Skip to content

Commit e5d2c51

Browse files
authored
Add update time and optimistic locking support for Firestore (#171)
Fixes: spring-attic/spring-cloud-gcp#2498.
1 parent 4f8bdc7 commit e5d2c51

File tree

17 files changed

+514
-49
lines changed

17 files changed

+514
-49
lines changed

docs/src/main/asciidoc/firestore.adoc

+26-1
Original file line numberDiff line numberDiff line change
@@ -297,7 +297,7 @@ Now when you call the methods annotated with `@Transactional` on your service ob
297297
If an error occurs during the execution of a method annotated with `@Transactional`, the transaction will be rolled back.
298298
If no error occurs, the transaction will be committed.
299299

300-
==== Subcollections
300+
=== Subcollections
301301
A subcollection is a collection associated with a specific entity.
302302
Documents in subcollections can contain subcollections as well, allowing you to further nest data. You can nest data up to 100 levels deep.
303303

@@ -321,6 +321,31 @@ include::{project-root}/spring-cloud-gcp-data-firestore/src/test/java/com/google
321321
322322
----
323323

324+
=== Update Time and Optimistic Locking
325+
Firestore stores update time for every document.
326+
If you would like to retrieve it, you can add a field of `com.google.cloud.Timestamp` type to your entity and annotate it with `@UpdateTime` annotation.
327+
328+
[source,java,indent=0]
329+
----
330+
@UpdateTime
331+
Timestamp updateTime;
332+
----
333+
334+
===== Using update time for optimistic locking
335+
A field annotated with `@UpdateTime` can be used for optimistic locking.
336+
To enable that, you need to set `version` parameter to `true`:
337+
338+
[source,java,indent=0]
339+
----
340+
@UpdateTime(version = true)
341+
Timestamp updateTime;
342+
----
343+
344+
When you enable optimistic locking, a precondition will be automatically added to the write request to ensure that the document you are updating was not changed since your last read.
345+
It uses this field's value as a document version and checks that the version of the document you write is the same as the one you've read.
346+
347+
If the field is empty, a precondition would check that the document with the same id does not exist to ensure you don't overwrite existing documents unintentionally.
348+
324349
=== Cloud Firestore Spring Boot Starter
325350

326351
If you prefer using Firestore client only, Spring Cloud GCP provides a convenience starter which automatically configures authentication settings and client objects needed to begin using https://cloud.google.com/firestore/[Google Cloud Firestore] in native mode.

spring-cloud-gcp-autoconfigure/src/main/java/com/google/cloud/spring/autoconfigure/firestore/GcpFirestoreAutoConfiguration.java

+4-4
Original file line numberDiff line numberDiff line change
@@ -131,8 +131,8 @@ public FirestoreMappingContext firestoreMappingContext() {
131131

132132
@Bean
133133
@ConditionalOnMissingBean
134-
public FirestoreClassMapper getClassMapper() {
135-
return new FirestoreDefaultClassMapper();
134+
public FirestoreClassMapper getClassMapper(FirestoreMappingContext mappingContext) {
135+
return new FirestoreDefaultClassMapper(mappingContext);
136136
}
137137

138138
@Bean
@@ -146,9 +146,9 @@ public FirestoreTemplate firestoreTemplate(FirestoreGrpc.FirestoreStub firestore
146146
@Bean
147147
@ConditionalOnMissingBean
148148
public ReactiveFirestoreTransactionManager firestoreTransactionManager(
149-
FirestoreGrpc.FirestoreStub firestoreStub) {
149+
FirestoreGrpc.FirestoreStub firestoreStub, FirestoreClassMapper classMapper) {
150150
return new ReactiveFirestoreTransactionManager(firestoreStub,
151-
GcpFirestoreAutoConfiguration.this.firestoreRootPath);
151+
GcpFirestoreAutoConfiguration.this.firestoreRootPath, classMapper);
152152
}
153153

154154
@Bean

spring-cloud-gcp-data-firestore/src/main/java/com/google/cloud/spring/data/firestore/FirestoreTemplate.java

+38-6
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,17 @@
1818

1919
import java.time.Duration;
2020
import java.util.List;
21+
import java.util.Objects;
2122
import java.util.Optional;
2223
import java.util.function.Consumer;
2324
import java.util.function.Function;
2425

26+
import com.google.cloud.Timestamp;
2527
import com.google.cloud.spring.data.firestore.mapping.FirestoreClassMapper;
2628
import com.google.cloud.spring.data.firestore.mapping.FirestoreMappingContext;
2729
import com.google.cloud.spring.data.firestore.mapping.FirestorePersistentEntity;
2830
import com.google.cloud.spring.data.firestore.mapping.FirestorePersistentProperty;
31+
import com.google.cloud.spring.data.firestore.mapping.UpdateTime;
2932
import com.google.cloud.spring.data.firestore.transaction.ReactiveFirestoreResourceHolder;
3033
import com.google.cloud.spring.data.firestore.util.ObservableReactiveUtil;
3134
import com.google.cloud.spring.data.firestore.util.Util;
@@ -204,11 +207,13 @@ public <T> Flux<T> saveAll(Publisher<T> instances) {
204207
if (transactionContext.isPresent()) {
205208
ReactiveFirestoreResourceHolder holder = (ReactiveFirestoreResourceHolder) transactionContext.get()
206209
.getResources().get(this.firestore);
207-
List<Write> writes = holder.getWrites();
208210
//In a transaction, all write operations should be sent in the commit request, so we just collect them
209-
return Flux.from(instances).doOnNext(t -> writes.add(createUpdateWrite(t)));
211+
return Flux.from(instances).doOnNext(t -> {
212+
holder.getWrites().add(createUpdateWrite(t));
213+
holder.getEntities().add(t);
214+
});
210215
}
211-
return commitWrites(instances, this::createUpdateWrite);
216+
return commitWrites(instances, this::createUpdateWrite, true);
212217
});
213218
}
214219

@@ -288,11 +293,12 @@ private Flux<String> deleteDocumentsByName(Flux<String> documentNames) {
288293
//In a transaction, all write operations should be sent in the commit request, so we just collect them
289294
return Flux.from(documentNames).doOnNext(t -> writes.add(createDeleteWrite(t)));
290295
}
291-
return commitWrites(documentNames, this::createDeleteWrite);
296+
return commitWrites(documentNames, this::createDeleteWrite, false);
292297
});
293298
}
294299

295-
private <T> Flux<T> commitWrites(Publisher<T> instances, Function<T, Write> converterToWrite) {
300+
private <T> Flux<T> commitWrites(Publisher<T> instances, Function<T, Write> converterToWrite,
301+
boolean setUpdateTime) {
296302
return Flux.from(instances).bufferTimeout(this.writeBufferSize, this.writeBufferTimeout)
297303
.flatMap(batch -> {
298304
CommitRequest.Builder builder = CommitRequest.newBuilder()
@@ -302,7 +308,16 @@ private <T> Flux<T> commitWrites(Publisher<T> instances, Function<T, Write> conv
302308

303309
return ObservableReactiveUtil
304310
.<CommitResponse>unaryCall(obs -> this.firestore.commit(builder.build(), obs))
305-
.thenMany(Flux.fromIterable(batch));
311+
.flatMapMany(
312+
response -> {
313+
if (setUpdateTime) {
314+
for (T entity : batch) {
315+
getClassMapper()
316+
.setUpdateTime(entity, Timestamp.fromProto(response.getCommitTime()));
317+
}
318+
}
319+
return Flux.fromIterable(batch);
320+
});
306321
});
307322
}
308323

@@ -376,6 +391,23 @@ private <T> Write createUpdateWrite(T entity) {
376391
}
377392
String resourceName = buildResourceName(entity);
378393
Document document = getClassMapper().entityToDocument(entity, resourceName);
394+
FirestorePersistentEntity<?> persistentEntity =
395+
this.mappingContext.getPersistentEntity(entity.getClass());
396+
FirestorePersistentProperty updateTimeProperty =
397+
Objects.requireNonNull(persistentEntity).getUpdateTimeProperty();
398+
if (updateTimeProperty != null
399+
&& Objects.requireNonNull(updateTimeProperty.findAnnotation(UpdateTime.class)).version()) {
400+
Object version = persistentEntity.getPropertyAccessor(entity).getProperty(updateTimeProperty);
401+
if (version != null) {
402+
builder.setCurrentDocument(
403+
Precondition.newBuilder().setUpdateTime(((Timestamp) version).toProto()).build());
404+
}
405+
else {
406+
//If an entity with an empty update time field is being saved, it must be new.
407+
//Otherwise it will overwrite an existing document.
408+
builder.setCurrentDocument(Precondition.newBuilder().setExists(false).build());
409+
}
410+
}
379411
return builder.setUpdate(document).build();
380412
}
381413

spring-cloud-gcp-data-firestore/src/main/java/com/google/cloud/spring/data/firestore/mapping/FirestoreClassMapper.java

+4
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
package com.google.cloud.spring.data.firestore.mapping;
1818

19+
import com.google.cloud.Timestamp;
1920
import com.google.firestore.v1.Document;
2021
import com.google.firestore.v1.Value;
2122

@@ -55,4 +56,7 @@ public interface FirestoreClassMapper {
5556
* @return the entity that the Firestore document was converted to
5657
*/
5758
<T> T documentToEntity(Document document, Class<T> clazz);
59+
60+
<T> T setUpdateTime(T entity, Timestamp updateTime);
61+
5862
}

spring-cloud-gcp-data-firestore/src/main/java/com/google/cloud/spring/data/firestore/mapping/FirestoreDefaultClassMapper.java

+33-4
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
package com.google.cloud.spring.data.firestore.mapping;
1818

1919
import java.util.Map;
20+
import java.util.Objects;
2021

2122
import com.google.cloud.Timestamp;
2223
import com.google.cloud.firestore.DocumentSnapshot;
@@ -43,7 +44,10 @@ public final class FirestoreDefaultClassMapper implements FirestoreClassMapper {
4344

4445
private static final String NOT_USED_PATH = "/not/used/path";
4546

46-
public FirestoreDefaultClassMapper() {
47+
private FirestoreMappingContext mappingContext;
48+
49+
public FirestoreDefaultClassMapper(FirestoreMappingContext mappingContext) {
50+
this.mappingContext = mappingContext;
4751
}
4852

4953
public <T> Value toFirestoreValue(T sourceValue) {
@@ -54,14 +58,39 @@ public <T> Value toFirestoreValue(T sourceValue) {
5458

5559
public <T> Document entityToDocument(T entity, String documentResourceName) {
5660
DocumentSnapshot documentSnapshot = INTERNAL.snapshotFromObject(NOT_USED_PATH, entity);
57-
Map<String, Value> valuesMap = INTERNAL.protoFromSnapshot(documentSnapshot);
5861
return Document.newBuilder()
59-
.putAllFields(valuesMap)
62+
.putAllFields(removeUpdateTimestamp(INTERNAL.protoFromSnapshot(documentSnapshot), entity))
6063
.setName(documentResourceName).build();
6164
}
6265

6366
public <T> T documentToEntity(Document document, Class<T> clazz) {
6467
DocumentSnapshot documentSnapshot = INTERNAL.snapshotFromProto(Timestamp.now(), document);
65-
return documentSnapshot.toObject(clazz);
68+
T entity = documentSnapshot.toObject(clazz);
69+
return setUpdateTime(entity, documentSnapshot.getUpdateTime());
70+
}
71+
72+
public <T> T setUpdateTime(T entity, Timestamp updateTime) {
73+
FirestorePersistentEntity<?> persistentEntity =
74+
this.mappingContext.getPersistentEntity(entity.getClass());
75+
FirestorePersistentProperty updateTimeProperty =
76+
Objects.requireNonNull(persistentEntity).getUpdateTimeProperty();
77+
78+
if (updateTimeProperty != null) {
79+
persistentEntity.getPropertyAccessor(entity).setProperty(updateTimeProperty, updateTime);
80+
}
81+
82+
return entity;
83+
}
84+
85+
private Map<String, Value> removeUpdateTimestamp(Map<String, Value> valuesMap, Object entity) {
86+
FirestorePersistentEntity<?> persistentEntity =
87+
this.mappingContext.getPersistentEntity(entity.getClass());
88+
FirestorePersistentProperty updateTimeProperty =
89+
Objects.requireNonNull(persistentEntity).getUpdateTimeProperty();
90+
if (updateTimeProperty != null) {
91+
valuesMap.remove(updateTimeProperty.getFieldName());
92+
}
93+
return valuesMap;
6694
}
95+
6796
}

spring-cloud-gcp-data-firestore/src/main/java/com/google/cloud/spring/data/firestore/mapping/FirestorePersistentEntity.java

+2
Original file line numberDiff line numberDiff line change
@@ -44,4 +44,6 @@ public interface FirestorePersistentEntity<T> extends
4444
* @return the ID property.
4545
*/
4646
FirestorePersistentProperty getIdPropertyOrFail();
47+
48+
FirestorePersistentProperty getUpdateTimeProperty();
4749
}

spring-cloud-gcp-data-firestore/src/main/java/com/google/cloud/spring/data/firestore/mapping/FirestorePersistentEntityImpl.java

+19
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
package com.google.cloud.spring.data.firestore.mapping;
1818

19+
import com.google.cloud.Timestamp;
1920
import com.google.cloud.spring.data.firestore.Document;
2021
import com.google.cloud.spring.data.firestore.FirestoreDataException;
2122

@@ -37,6 +38,8 @@ public class FirestorePersistentEntityImpl<T>
3738

3839
private final String collectionName;
3940

41+
private FirestorePersistentProperty updateTimeProperty;
42+
4043
public FirestorePersistentEntityImpl(TypeInformation<T> information) {
4144
super(information);
4245
this.collectionName = getEntityCollectionName(information);
@@ -62,6 +65,11 @@ public FirestorePersistentProperty getIdPropertyOrFail() {
6265
return idProperty;
6366
}
6467

68+
@Override
69+
public FirestorePersistentProperty getUpdateTimeProperty() {
70+
return updateTimeProperty;
71+
}
72+
6573
private static <T> String getEntityCollectionName(TypeInformation<T> typeInformation) {
6674
Document document = AnnotationUtils.findAnnotation(typeInformation.getType(), Document.class);
6775
String collectionName = (String) AnnotationUtils.getValue(document, "collectionName");
@@ -74,4 +82,15 @@ private static <T> String getEntityCollectionName(TypeInformation<T> typeInforma
7482
return collectionName;
7583
}
7684
}
85+
86+
@Override
87+
public void addPersistentProperty(FirestorePersistentProperty property) {
88+
super.addPersistentProperty(property);
89+
if (property.findAnnotation(UpdateTime.class) != null) {
90+
if (property.getActualType() != Timestamp.class) {
91+
throw new FirestoreDataException("@UpdateTime annotated field should be of com.google.cloud.Timestamp type");
92+
}
93+
updateTimeProperty = property;
94+
}
95+
}
7796
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/*
2+
* Copyright 2020-2020 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.google.cloud.spring.data.firestore.mapping;
18+
19+
import java.lang.annotation.ElementType;
20+
import java.lang.annotation.Retention;
21+
import java.lang.annotation.RetentionPolicy;
22+
import java.lang.annotation.Target;
23+
24+
/**
25+
* Marks a field to be used for update time.
26+
*
27+
* @author Dmitry Solomakha
28+
*
29+
* @since 2.0.0
30+
*/
31+
@Retention(RetentionPolicy.RUNTIME)
32+
@Target({ ElementType.METHOD, ElementType.FIELD })
33+
public @interface UpdateTime {
34+
boolean version() default false;
35+
}

spring-cloud-gcp-data-firestore/src/main/java/com/google/cloud/spring/data/firestore/transaction/ReactiveFirestoreResourceHolder.java

+6
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ public class ReactiveFirestoreResourceHolder extends ResourceHolderSupport {
3434

3535
private List<Write> writes = new ArrayList<>();
3636

37+
private List<Object> entities = new ArrayList<>();
38+
3739
public ReactiveFirestoreResourceHolder(ByteString transactionId) {
3840
this.transactionId = transactionId;
3941
}
@@ -45,4 +47,8 @@ public ByteString getTransactionId() {
4547
public List<Write> getWrites() {
4648
return this.writes;
4749
}
50+
51+
public List<Object> getEntities() {
52+
return entities;
53+
}
4854
}

spring-cloud-gcp-data-firestore/src/main/java/com/google/cloud/spring/data/firestore/transaction/ReactiveFirestoreTransactionManager.java

+16-3
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,16 @@
1616

1717
package com.google.cloud.spring.data.firestore.transaction;
1818

19+
import com.google.cloud.Timestamp;
20+
import com.google.cloud.spring.data.firestore.mapping.FirestoreClassMapper;
1921
import com.google.cloud.spring.data.firestore.util.ObservableReactiveUtil;
2022
import com.google.cloud.spring.data.firestore.util.Util;
2123
import com.google.firestore.v1.BeginTransactionRequest;
2224
import com.google.firestore.v1.BeginTransactionResponse;
2325
import com.google.firestore.v1.CommitRequest;
2426
import com.google.firestore.v1.CommitResponse;
2527
import com.google.firestore.v1.FirestoreGrpc;
28+
import com.google.firestore.v1.FirestoreGrpc.FirestoreStub;
2629
import com.google.firestore.v1.RollbackRequest;
2730
import com.google.firestore.v1.TransactionOptions;
2831
import com.google.protobuf.ByteString;
@@ -50,16 +53,19 @@ public class ReactiveFirestoreTransactionManager extends AbstractReactiveTransac
5053

5154
private final String databasePath;
5255

56+
private FirestoreClassMapper classMapper;
57+
5358
/**
5459
* Constructor for ReactiveFirestoreTransactionManager.
5560
* @param firestore Firestore gRPC stub
5661
* @param parent the parent resource. For example:
5762
* projects/{project_id}/databases/{database_id}/documents or
58-
* projects/{project_id}/databases/{database_id}/documents/chatrooms/{chatroom_id}
63+
* @param classMapper Firestore class mapper
5964
*/
60-
public ReactiveFirestoreTransactionManager(FirestoreGrpc.FirestoreStub firestore, String parent) {
65+
public ReactiveFirestoreTransactionManager(FirestoreStub firestore, String parent, FirestoreClassMapper classMapper) {
6166
this.firestore = firestore;
6267
this.databasePath = Util.extractDatabasePath(parent);
68+
this.classMapper = classMapper;
6369
}
6470

6571
@Override
@@ -101,10 +107,17 @@ protected Mono<Void> doCommit(TransactionSynchronizationManager transactionSynch
101107

102108
return ObservableReactiveUtil
103109
.<CommitResponse>unaryCall(obs -> this.firestore.commit(builder.build(), obs))
104-
.then();
110+
.flatMap((response) -> {
111+
for (Object entity : resourceHolder.getEntities()) {
112+
this.classMapper.setUpdateTime(entity, Timestamp.fromProto(response.getCommitTime()));
113+
}
114+
return Mono.empty();
115+
}
116+
);
105117
});
106118
}
107119

120+
108121
@Override
109122
protected Mono<Void> doRollback(TransactionSynchronizationManager transactionSynchronizationManager,
110123
GenericReactiveTransaction genericReactiveTransaction) throws TransactionException {

0 commit comments

Comments
 (0)