Skip to content

Commit 7ea5d01

Browse files
Add Array Features to Firestore Java
1 parent 1ecbe07 commit 7ea5d01

12 files changed

Lines changed: 517 additions & 59 deletions

File tree

google-cloud-clients/google-cloud-firestore/src/main/java/com/google/cloud/firestore/CustomClassMapper.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,8 @@ private static <T> Object serialize(T o, ErrorPath path) {
160160
|| o instanceof Timestamp
161161
|| o instanceof GeoPoint
162162
|| o instanceof Blob
163-
|| o instanceof DocumentReference) {
163+
|| o instanceof DocumentReference
164+
|| o instanceof FieldValue) {
164165
return o;
165166
} else {
166167
Class<T> clazz = (Class<T>) o.getClass();

google-cloud-clients/google-cloud-firestore/src/main/java/com/google/cloud/firestore/DocumentMask.java

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,10 +48,10 @@ private static List<FieldPath> extractFromMap(Map<String, Object> values, FieldP
4848
for (Map.Entry<String, Object> entry : values.entrySet()) {
4949
Object value = entry.getValue();
5050
FieldPath childPath = path.append(FieldPath.of(entry.getKey()));
51-
if (entry.getValue() == FieldValue.SERVER_TIMESTAMP_SENTINEL) {
52-
// Ignore
53-
} else if (entry.getValue() == FieldValue.DELETE_SENTINEL) {
54-
fieldPaths.add(childPath);
51+
if (entry.getValue() instanceof FieldValue) {
52+
if (((FieldValue) entry.getValue()).includeInDocumentMask()) {
53+
fieldPaths.add(childPath);
54+
}
5555
} else if (value instanceof Map) {
5656
fieldPaths.addAll(extractFromMap((Map<String, Object>) value, childPath));
5757
} else {

google-cloud-clients/google-cloud-firestore/src/main/java/com/google/cloud/firestore/DocumentTransform.java

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -46,11 +46,11 @@ static DocumentTransform fromFieldPathMap(
4646
for (Map.Entry<FieldPath, Object> entry : values.entrySet()) {
4747
FieldPath path = entry.getKey();
4848
Object value = entry.getValue();
49-
if (value == FieldValue.SERVER_TIMESTAMP_SENTINEL) {
50-
FieldTransform.Builder fieldTransform = FieldTransform.newBuilder();
51-
fieldTransform.setFieldPath(path.getEncodedPath());
52-
fieldTransform.setSetToServerValue(FieldTransform.ServerValue.REQUEST_TIME);
53-
transforms.put(path, fieldTransform.build());
49+
if (value instanceof FieldValue) {
50+
FieldValue fieldValue = (FieldValue) value;
51+
if (fieldValue.includeInDocumentTransform()) {
52+
transforms.put(path, fieldValue.toProto(path));
53+
}
5454
} else if (value instanceof Map) {
5555
transforms.putAll(
5656
extractFromMap((Map<String, Object>) value, path, /* allowTransforms= */ true));
@@ -71,15 +71,15 @@ private static SortedMap<FieldPath, FieldTransform> extractFromMap(
7171
for (Map.Entry<String, Object> entry : values.entrySet()) {
7272
Object value = entry.getValue();
7373
path = path.append(FieldPath.of(entry.getKey()));
74-
if (value == FieldValue.SERVER_TIMESTAMP_SENTINEL) {
74+
if (value instanceof FieldValue) {
75+
FieldValue fieldValue = (FieldValue) value;
7576
if (allowTransforms) {
76-
FieldTransform.Builder fieldTransform = FieldTransform.newBuilder();
77-
fieldTransform.setFieldPath(path.getEncodedPath());
78-
fieldTransform.setSetToServerValue(FieldTransform.ServerValue.REQUEST_TIME);
79-
transforms.put(path, fieldTransform.build());
77+
if (fieldValue.includeInDocumentTransform()) {
78+
transforms.put(path, fieldValue.toProto(path));
79+
}
8080
} else {
8181
throw FirestoreException.invalidState(
82-
"Server timestamps are not supported as Array values.");
82+
fieldValue.getMethodName() + " is not supported inside of an array.");
8383
}
8484
} else if (value instanceof Map) {
8585
transforms.putAll(extractFromMap((Map<String, Object>) value, path, allowTransforms));
@@ -96,9 +96,9 @@ private static void validateArray(List<Object> values, FieldPath path) {
9696
for (int i = 0; i < values.size(); ++i) {
9797
Object value = values.get(i);
9898
path = path.append(FieldPath.of(Integer.toString(i)));
99-
if (value == FieldValue.SERVER_TIMESTAMP_SENTINEL) {
99+
if (value instanceof FieldValue) {
100100
throw FirestoreException.invalidState(
101-
"Server timestamps are not supported as Array values.");
101+
((FieldValue) value).getMethodName() + " is not supported inside of an array.");
102102
} else if (value instanceof Map) {
103103
extractFromMap((Map<String, Object>) value, path, false);
104104
} else if (value instanceof List) {

google-cloud-clients/google-cloud-firestore/src/main/java/com/google/cloud/firestore/FieldValue.java

Lines changed: 175 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,141 @@
1616

1717
package com.google.cloud.firestore;
1818

19+
import com.google.common.base.Preconditions;
20+
import com.google.firestore.v1beta1.ArrayValue;
21+
import com.google.firestore.v1beta1.DocumentTransform.FieldTransform;
22+
import java.util.Arrays;
23+
import java.util.List;
1924
import javax.annotation.Nonnull;
2025

2126
/** Sentinel values that can be used when writing document fields with set() or update(). */
2227
public abstract class FieldValue {
2328

24-
static final Object SERVER_TIMESTAMP_SENTINEL = new Object();
25-
static final Object DELETE_SENTINEL = new Object();
29+
private static final FieldValue SERVER_TIMESTAMP_SENTINEL =
30+
new FieldValue() {
31+
@Override
32+
boolean includeInDocumentMask() {
33+
return false;
34+
}
35+
36+
@Override
37+
boolean includeInDocumentTransform() {
38+
return true;
39+
}
40+
41+
@Override
42+
String getMethodName() {
43+
return "FieldValue.serverTimestamp()";
44+
}
45+
46+
@Override
47+
FieldTransform toProto(FieldPath path) {
48+
FieldTransform.Builder fieldTransform = FieldTransform.newBuilder();
49+
fieldTransform.setFieldPath(path.getEncodedPath());
50+
fieldTransform.setSetToServerValue(FieldTransform.ServerValue.REQUEST_TIME);
51+
return fieldTransform.build();
52+
}
53+
};
54+
55+
static final FieldValue DELETE_SENTINEL =
56+
new FieldValue() {
57+
@Override
58+
boolean includeInDocumentMask() {
59+
return true;
60+
}
61+
62+
@Override
63+
boolean includeInDocumentTransform() {
64+
return false;
65+
}
66+
67+
@Override
68+
String getMethodName() {
69+
return "FieldValue.delete()";
70+
}
71+
72+
@Override
73+
FieldTransform toProto(FieldPath path) {
74+
throw new IllegalStateException(
75+
"FieldValue.delete() should not be included in a FieldTransform");
76+
}
77+
};
78+
79+
static class ArrayUnionFieldValue extends FieldValue {
80+
final List<Object> elements;
81+
82+
ArrayUnionFieldValue(List<Object> elements) {
83+
this.elements = elements;
84+
}
85+
86+
@Override
87+
boolean includeInDocumentMask() {
88+
return false;
89+
}
90+
91+
@Override
92+
boolean includeInDocumentTransform() {
93+
return true;
94+
}
95+
96+
@Override
97+
String getMethodName() {
98+
return "FieldValue.arrayUnion()";
99+
}
100+
101+
@Override
102+
FieldTransform toProto(FieldPath path) {
103+
ArrayValue.Builder encodedElements = ArrayValue.newBuilder();
104+
105+
for (Object element : elements) {
106+
encodedElements.addValues(
107+
UserDataConverter.encodeValue(path, element, UserDataConverter.ARGUMENT));
108+
}
109+
110+
FieldTransform.Builder fieldTransform = FieldTransform.newBuilder();
111+
fieldTransform.setFieldPath(path.getEncodedPath());
112+
fieldTransform.setAppendMissingElements(encodedElements);
113+
return fieldTransform.build();
114+
}
115+
}
116+
117+
static class ArrayRemoveFieldValue extends FieldValue {
118+
final List<Object> elements;
119+
120+
ArrayRemoveFieldValue(List<Object> elements) {
121+
this.elements = elements;
122+
}
123+
124+
@Override
125+
boolean includeInDocumentMask() {
126+
return false;
127+
}
128+
129+
@Override
130+
boolean includeInDocumentTransform() {
131+
return true;
132+
}
133+
134+
@Override
135+
String getMethodName() {
136+
return "FieldValue.arrayRemove()";
137+
}
138+
139+
@Override
140+
FieldTransform toProto(FieldPath path) {
141+
ArrayValue.Builder encodedElements = ArrayValue.newBuilder();
142+
143+
for (Object element : elements) {
144+
encodedElements.addValues(
145+
UserDataConverter.encodeValue(path, element, UserDataConverter.ARGUMENT));
146+
}
147+
148+
FieldTransform.Builder fieldTransform = FieldTransform.newBuilder();
149+
fieldTransform.setFieldPath(path.getEncodedPath());
150+
fieldTransform.setRemoveAllFromArray(encodedElements);
151+
return fieldTransform.build();
152+
}
153+
}
26154

27155
private FieldValue() {}
28156

@@ -31,16 +159,59 @@ private FieldValue() {}
31159
* written data.
32160
*/
33161
@Nonnull
34-
public static Object serverTimestamp() {
162+
public static FieldValue serverTimestamp() {
35163
return SERVER_TIMESTAMP_SENTINEL;
36164
}
37165

38166
/** Returns a sentinel used with update() to mark a field for deletion. */
39167
@Nonnull
40-
public static Object delete() {
168+
public static FieldValue delete() {
41169
return DELETE_SENTINEL;
42170
}
43171

172+
/**
173+
* Returns a special value that can be used with set() or update() that tells the server to union
174+
* the given elements with any array value that already exists on the server. Each specified
175+
* element that doesn't already exist in the array will be added to the end. If the field being
176+
* modified is not already an array it will be overwritten with an array containing exactly the
177+
* specified elements.
178+
*
179+
* @param elements The elements to union into the array.
180+
* @return The FieldValue sentinel for use in a call to set() or update().
181+
*/
182+
@Nonnull
183+
public static FieldValue arrayUnion(@Nonnull Object... elements) {
184+
Preconditions.checkArgument(elements.length > 0, "arrayUnion() expects at least 1 element");
185+
return new ArrayUnionFieldValue(Arrays.asList(elements));
186+
}
187+
188+
/**
189+
* Returns a special value that can be used with set() or update() that tells the server to remove
190+
* the given elements from any array value that already exists on the server. All instances of
191+
* each element specified will be removed from the array. If the field being modified is not
192+
* already an array it will be overwritten with an empty array.
193+
*
194+
* @param elements The elements to remove from the array.
195+
* @return The FieldValue sentinel for use in a call to set() or update().
196+
*/
197+
@Nonnull
198+
public static FieldValue arrayRemove(@Nonnull Object... elements) {
199+
Preconditions.checkArgument(elements.length > 0, "arrayRemove() expects at least 1 element");
200+
return new ArrayRemoveFieldValue(Arrays.asList(elements));
201+
}
202+
203+
/** Whether this FieldTransform should be included in the document mask. */
204+
abstract boolean includeInDocumentMask();
205+
206+
/** Whether this FieldTransform should be included in the list of document transforms. */
207+
abstract boolean includeInDocumentTransform();
208+
209+
/** The name of the method that returned this FieldValue instance. */
210+
abstract String getMethodName();
211+
212+
/** Generates the field transform proto. */
213+
abstract FieldTransform toProto(FieldPath path);
214+
44215
/**
45216
* Returns true if this FieldValue is equal to the provided object.
46217
*

google-cloud-clients/google-cloud-firestore/src/main/java/com/google/cloud/firestore/Query.java

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
package com.google.cloud.firestore;
1818

19+
import static com.google.firestore.v1beta1.StructuredQuery.FieldFilter.Operator.ARRAY_CONTAINS;
1920
import static com.google.firestore.v1beta1.StructuredQuery.FieldFilter.Operator.EQUAL;
2021
import static com.google.firestore.v1beta1.StructuredQuery.FieldFilter.Operator.GREATER_THAN;
2122
import static com.google.firestore.v1beta1.StructuredQuery.FieldFilter.Operator.GREATER_THAN_OR_EQUAL;
@@ -89,7 +90,7 @@ private abstract static class FieldFilter {
8990
Value encodeValue() {
9091
Object sanitizedObject = CustomClassMapper.serialize(value);
9192
Value encodedValue =
92-
UserDataConverter.encodeValue(fieldPath, sanitizedObject, UserDataConverter.NO_DELETES);
93+
UserDataConverter.encodeValue(fieldPath, sanitizedObject, UserDataConverter.ARGUMENT);
9394

9495
if (encodedValue == null) {
9596
throw FirestoreException.invalidState("Cannot use Firestore Sentinels in FieldFilter");
@@ -351,7 +352,7 @@ private Cursor createCursor(List<FieldOrder> order, Object[] fieldValues, boolea
351352
}
352353

353354
Value encodedValue =
354-
UserDataConverter.encodeValue(fieldPath, sanitizedValue, UserDataConverter.NO_DELETES);
355+
UserDataConverter.encodeValue(fieldPath, sanitizedValue, UserDataConverter.ARGUMENT);
355356

356357
if (encodedValue == null) {
357358
throw FirestoreException.invalidState(
@@ -567,6 +568,44 @@ public Query whereGreaterThanOrEqualTo(@Nonnull FieldPath fieldPath, @Nonnull Ob
567568
return new Query(firestore, path, newOptions);
568569
}
569570

571+
/**
572+
* Creates and returns a new Query with the additional filter that documents must contain the
573+
* specified field, the value must be an array, and that the array must contain the provided
574+
* value.
575+
*
576+
* <p>A Query can have only one whereArrayContains() filter.
577+
*
578+
* @param field The name of the field containing an array to search
579+
* @param value The value that must be contained in the array
580+
* @return The created Query.
581+
*/
582+
@Nonnull
583+
public Query whereArrayContains(@Nonnull String field, @Nonnull Object value) {
584+
return whereArrayContains(FieldPath.fromDotSeparatedString(field), value);
585+
}
586+
587+
/**
588+
* Creates and returns a new Query with the additional filter that documents must contain the
589+
* specified field, the value must be an array, and that the array must contain the provided
590+
* value.
591+
*
592+
* <p>A Query can have only one whereArrayContains() filter.
593+
*
594+
* @param fieldPath The path of the field containing an array to search
595+
* @param value The value that must be contained in the array
596+
* @return The created Query.
597+
*/
598+
@Nonnull
599+
public Query whereArrayContains(@Nonnull FieldPath fieldPath, @Nonnull Object value) {
600+
Preconditions.checkState(
601+
options.startCursor == null && options.endCursor == null,
602+
"Cannot call whereArrayContains() after defining a boundary with startAt(), "
603+
+ "startAfter(), endBefore() or endAt().");
604+
QueryOptions newOptions = new QueryOptions(options);
605+
newOptions.fieldFilters.add(new ComparisonFilter(fieldPath, ARRAY_CONTAINS, value));
606+
return new Query(firestore, path, newOptions);
607+
}
608+
570609
/**
571610
* Creates and returns a new Query that's additionally sorted by the specified field.
572611
*

google-cloud-clients/google-cloud-firestore/src/main/java/com/google/cloud/firestore/SetOptions.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,11 @@ EncodingOptions getEncodingOptions() {
131131
public boolean allowDelete(FieldPath fieldPath) {
132132
return fieldMask.contains(fieldPath);
133133
}
134+
135+
@Override
136+
public boolean allowTransform() {
137+
return true;
138+
}
134139
};
135140
}
136141
}

google-cloud-clients/google-cloud-firestore/src/main/java/com/google/cloud/firestore/UpdateBuilder.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -518,6 +518,11 @@ private T performUpdate(
518518
public boolean allowDelete(FieldPath fieldPath) {
519519
return fields.containsKey(fieldPath);
520520
}
521+
522+
@Override
523+
public boolean allowTransform() {
524+
return true;
525+
}
521526
});
522527
List<FieldPath> fieldPaths = new ArrayList<>(fields.keySet());
523528
DocumentTransform documentTransform =

0 commit comments

Comments
 (0)