Skip to content

Commit 5281f1b

Browse files
Add Array Features to Firestore Java (#3561)
1 parent d76486f commit 5281f1b

File tree

13 files changed

+577
-59
lines changed

13 files changed

+577
-59
lines changed

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: 210 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,176 @@
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;
24+
import java.util.Objects;
1925
import javax.annotation.Nonnull;
2026

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

24-
static final Object SERVER_TIMESTAMP_SENTINEL = new Object();
25-
static final Object DELETE_SENTINEL = new Object();
30+
private static final FieldValue SERVER_TIMESTAMP_SENTINEL =
31+
new FieldValue() {
32+
@Override
33+
boolean includeInDocumentMask() {
34+
return false;
35+
}
36+
37+
@Override
38+
boolean includeInDocumentTransform() {
39+
return true;
40+
}
41+
42+
@Override
43+
String getMethodName() {
44+
return "FieldValue.serverTimestamp()";
45+
}
46+
47+
@Override
48+
FieldTransform toProto(FieldPath path) {
49+
FieldTransform.Builder fieldTransform = FieldTransform.newBuilder();
50+
fieldTransform.setFieldPath(path.getEncodedPath());
51+
fieldTransform.setSetToServerValue(FieldTransform.ServerValue.REQUEST_TIME);
52+
return fieldTransform.build();
53+
}
54+
};
55+
56+
static final FieldValue DELETE_SENTINEL =
57+
new FieldValue() {
58+
@Override
59+
boolean includeInDocumentMask() {
60+
return true;
61+
}
62+
63+
@Override
64+
boolean includeInDocumentTransform() {
65+
return false;
66+
}
67+
68+
@Override
69+
String getMethodName() {
70+
return "FieldValue.delete()";
71+
}
72+
73+
@Override
74+
FieldTransform toProto(FieldPath path) {
75+
throw new IllegalStateException(
76+
"FieldValue.delete() should not be included in a FieldTransform");
77+
}
78+
};
79+
80+
static class ArrayUnionFieldValue extends FieldValue {
81+
final List<Object> elements;
82+
83+
ArrayUnionFieldValue(List<Object> elements) {
84+
this.elements = elements;
85+
}
86+
87+
@Override
88+
boolean includeInDocumentMask() {
89+
return false;
90+
}
91+
92+
@Override
93+
boolean includeInDocumentTransform() {
94+
return true;
95+
}
96+
97+
@Override
98+
String getMethodName() {
99+
return "FieldValue.arrayUnion()";
100+
}
101+
102+
@Override
103+
FieldTransform toProto(FieldPath path) {
104+
ArrayValue.Builder encodedElements = ArrayValue.newBuilder();
105+
106+
for (Object element : elements) {
107+
encodedElements.addValues(
108+
UserDataConverter.encodeValue(path, element, UserDataConverter.ARGUMENT));
109+
}
110+
111+
FieldTransform.Builder fieldTransform = FieldTransform.newBuilder();
112+
fieldTransform.setFieldPath(path.getEncodedPath());
113+
fieldTransform.setAppendMissingElements(encodedElements);
114+
return fieldTransform.build();
115+
}
116+
117+
@Override
118+
public boolean equals(Object o) {
119+
if (this == o) {
120+
return true;
121+
}
122+
if (o == null || getClass() != o.getClass()) {
123+
return false;
124+
}
125+
ArrayUnionFieldValue that = (ArrayUnionFieldValue) o;
126+
return Objects.equals(elements, that.elements);
127+
}
128+
129+
@Override
130+
public int hashCode() {
131+
return Objects.hash(elements);
132+
}
133+
}
134+
135+
static class ArrayRemoveFieldValue extends FieldValue {
136+
final List<Object> elements;
137+
138+
ArrayRemoveFieldValue(List<Object> elements) {
139+
this.elements = elements;
140+
}
141+
142+
@Override
143+
boolean includeInDocumentMask() {
144+
return false;
145+
}
146+
147+
@Override
148+
boolean includeInDocumentTransform() {
149+
return true;
150+
}
151+
152+
@Override
153+
String getMethodName() {
154+
return "FieldValue.arrayRemove()";
155+
}
156+
157+
@Override
158+
FieldTransform toProto(FieldPath path) {
159+
ArrayValue.Builder encodedElements = ArrayValue.newBuilder();
160+
161+
for (Object element : elements) {
162+
encodedElements.addValues(
163+
UserDataConverter.encodeValue(path, element, UserDataConverter.ARGUMENT));
164+
}
165+
166+
FieldTransform.Builder fieldTransform = FieldTransform.newBuilder();
167+
fieldTransform.setFieldPath(path.getEncodedPath());
168+
fieldTransform.setRemoveAllFromArray(encodedElements);
169+
return fieldTransform.build();
170+
}
171+
172+
@Override
173+
public boolean equals(Object o) {
174+
if (this == o) {
175+
return true;
176+
}
177+
if (o == null || getClass() != o.getClass()) {
178+
return false;
179+
}
180+
ArrayRemoveFieldValue that = (ArrayRemoveFieldValue) o;
181+
return Objects.equals(elements, that.elements);
182+
}
183+
184+
@Override
185+
public int hashCode() {
186+
return Objects.hash(elements);
187+
}
188+
}
26189

27190
private FieldValue() {}
28191

@@ -31,16 +194,59 @@ private FieldValue() {}
31194
* written data.
32195
*/
33196
@Nonnull
34-
public static Object serverTimestamp() {
197+
public static FieldValue serverTimestamp() {
35198
return SERVER_TIMESTAMP_SENTINEL;
36199
}
37200

38201
/** Returns a sentinel used with update() to mark a field for deletion. */
39202
@Nonnull
40-
public static Object delete() {
203+
public static FieldValue delete() {
41204
return DELETE_SENTINEL;
42205
}
43206

207+
/**
208+
* Returns a special value that can be used with set() or update() that tells the server to union
209+
* the given elements with any array value that already exists on the server. Each specified
210+
* element that doesn't already exist in the array will be added to the end. If the field being
211+
* modified is not already an array it will be overwritten with an array containing exactly the
212+
* specified elements.
213+
*
214+
* @param elements The elements to union into the array.
215+
* @return The FieldValue sentinel for use in a call to set() or update().
216+
*/
217+
@Nonnull
218+
public static FieldValue arrayUnion(@Nonnull Object... elements) {
219+
Preconditions.checkArgument(elements.length > 0, "arrayUnion() expects at least 1 element");
220+
return new ArrayUnionFieldValue(Arrays.asList(elements));
221+
}
222+
223+
/**
224+
* Returns a special value that can be used with set() or update() that tells the server to remove
225+
* the given elements from any array value that already exists on the server. All instances of
226+
* each element specified will be removed from the array. If the field being modified is not
227+
* already an array it will be overwritten with an empty array.
228+
*
229+
* @param elements The elements to remove from the array.
230+
* @return The FieldValue sentinel for use in a call to set() or update().
231+
*/
232+
@Nonnull
233+
public static FieldValue arrayRemove(@Nonnull Object... elements) {
234+
Preconditions.checkArgument(elements.length > 0, "arrayRemove() expects at least 1 element");
235+
return new ArrayRemoveFieldValue(Arrays.asList(elements));
236+
}
237+
238+
/** Whether this FieldTransform should be included in the document mask. */
239+
abstract boolean includeInDocumentMask();
240+
241+
/** Whether this FieldTransform should be included in the list of document transforms. */
242+
abstract boolean includeInDocumentTransform();
243+
244+
/** The name of the method that returned this FieldValue instance. */
245+
abstract String getMethodName();
246+
247+
/** Generates the field transform proto. */
248+
abstract FieldTransform toProto(FieldPath path);
249+
44250
/**
45251
* Returns true if this FieldValue is equal to the provided object.
46252
*

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
}

0 commit comments

Comments
 (0)