diff --git a/google-cloud-firestore/src/main/java/com/google/cloud/firestore/pipeline/expressions/Expression.java b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/pipeline/expressions/Expression.java index 363046c39..8dfbc1423 100644 --- a/google-cloud-firestore/src/main/java/com/google/cloud/firestore/pipeline/expressions/Expression.java +++ b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/pipeline/expressions/Expression.java @@ -1955,6 +1955,172 @@ public static Expression mapRemove(String mapField, String key) { return mapRemove(field(mapField), key); } + /** + * Creates an expression that returns a new map with the specified entries added or updated. + * + * + * + * @param mapExpr The expression representing the map. + * @param key The key to set. Must be an expression representing a string. + * @param value The value to set. + * @param moreKeyValues Additional key-value pairs to set. + * @return A new {@link Expression} representing the map with the entries set. + */ + @BetaApi + public static Expression mapSet( + Expression mapExpr, Expression key, Expression value, Expression... moreKeyValues) { + ImmutableList.Builder builder = ImmutableList.builder(); + builder.add(mapExpr); + builder.add(key); + builder.add(value); + builder.add(moreKeyValues); + return new FunctionExpression("map_set", builder.build()); + } + + /** + * Creates an expression that returns a new map with the specified entries added or updated. + * + * + * + * @param mapField The map field to set entries in. + * @param key The key to set. + * @param value The value to set. + * @param moreKeyValues Additional key-value pairs to set. + * @return A new {@link Expression} representing the map with the entries set. + */ + @BetaApi + public static Expression mapSet( + Expression mapExpr, String key, Object value, Object... moreKeyValues) { + return mapSet( + mapExpr, + constant(key), + toExprOrConstant(value), + toArrayOfExprOrConstant(moreKeyValues).toArray(new Expression[0])); + } + + /** + * Creates an expression that returns a new map with the specified entries added or updated. + * + * + * + * @param mapField The map field to set entries in. + * @param key The key to set. Must be an expression representing a string. + * @param value The value to set. + * @param moreKeyValues Additional key-value pairs to set. + * @return A new {@link Expression} representing the map with the entries set. + */ + @BetaApi + public static Expression mapSet( + String mapField, Expression key, Expression value, Expression... moreKeyValues) { + return mapSet(field(mapField), key, value, moreKeyValues); + } + + /** + * Creates an expression that returns a new map with the specified entries added or updated. + * + * + * + * @param mapField The map field to set entries in. + * @param key The key to set. Must be an expression representing a string. + * @param value The value to set. + * @param moreKeyValues Additional key-value pairs to set. + * @return A new {@link Expression} representing the map with the entries set. + */ + @BetaApi + public static Expression mapSet( + String mapField, String key, Object value, Object... moreKeyValues) { + return mapSet(field(mapField), key, value, moreKeyValues); + } + + /** + * Creates an expression that returns the keys of a map. + * + * @param mapExpr The map expression to get the keys of. + * @return A new {@link Expression} representing the keys of the map. + */ + @BetaApi + public static Expression mapKeys(Expression mapExpr) { + return new FunctionExpression("map_keys", ImmutableList.of(mapExpr)); + } + + /** + * Creates an expression that returns the keys of a map. + * + * @param mapField The map field to get the keys of. + * @return A new {@link Expression} representing the keys of the map. + */ + @BetaApi + public static Expression mapKeys(String mapField) { + return mapKeys(field(mapField)); + } + + /** + * Creates an expression that returns the values of a map. + * + *

While the backend generally preserves insertion order, relying on the order of the output + * array is not guaranteed and should be avoided. + * + * @param mapExpr The expression representing the map to get the values of. + * @return A new {@link Expression} representing the values of the map. + */ + @BetaApi + public static Expression mapValues(Expression mapExpr) { + return new FunctionExpression("map_values", ImmutableList.of(mapExpr)); + } + + /** + * Creates an expression that returns the values of a map. + * + * @param mapField The map field to get the values of. + * @return A new {@link Expression} representing the values of the map. + */ + @BetaApi + public static Expression mapValues(String mapField) { + return mapValues(field(mapField)); + } + + /** + * Creates an expression that returns the entries of a map as an array of maps, where each map + * contains a "k" property for the key and a "v" property for the value. + * + *

While the backend generally preserves insertion order, relying on the order of the output + * array is not guaranteed and should be avoided. + * + * @param mapExpr The expression representing the map to get the entries of. + * @return A new {@link Expression} representing the entries of the map. + */ + @BetaApi + public static Expression mapEntries(Expression mapExpr) { + return new FunctionExpression("map_entries", ImmutableList.of(mapExpr)); + } + + /** + * Creates an expression that returns the entries of a map as an array of maps. + * + * @param mapField The map field to get the entries of. + * @return A new {@link Expression} representing the entries of the map. + */ + @BetaApi + public static Expression mapEntries(String mapField) { + return mapEntries(field(mapField)); + } + /** * Creates an expression that reverses a string, blob, or array. * @@ -4376,6 +4542,80 @@ public final Expression mapRemove(String key) { return mapRemove(this, key); } + /** + * Creates an expression that returns a new map with the specified entries added or updated. + * + *

Note that {@code mapSet} only performs shallow updates to the map. Setting a value to {@code + * null} will retain the key with a {@code null} value. To remove a key entirely, use {@code + * mapRemove}. + * + * @param key The key to set. + * @param value The value to set. + * @param moreKeyValues Additional key-value pairs to set. + * @return A new {@link Expression} representing the map with the entries set. + */ + @BetaApi + public final Expression mapSet(Expression key, Expression value, Expression... moreKeyValues) { + return mapSet(this, key, value, moreKeyValues); + } + + /** + * Creates an expression that returns a new map with the specified entries added or updated. + * + * @param key The key to set. + * @param value The value to set. + * @param moreKeyValues Additional key-value pairs to set. + * @return A new {@link Expression} representing the map with the entries set. + */ + @BetaApi + public final Expression mapSet(String key, Object value, Object... moreKeyValues) { + return mapSet( + this, + constant(key), + toExprOrConstant(value), + toArrayOfExprOrConstant(moreKeyValues).toArray(new Expression[0])); + } + + /** + * Creates an expression that returns the keys of this map expression. + * + *

While the backend generally preserves insertion order, relying on the order of the output + * array is not guaranteed and should be avoided. + * + * @return A new {@link Expression} representing the keys of the map. + */ + @BetaApi + public final Expression mapKeys() { + return mapKeys(this); + } + + /** + * Creates an expression that returns the values of this map expression. + * + *

While the backend generally preserves insertion order, relying on the order of the output + * array is not guaranteed and should be avoided. + * + * @return A new {@link Expression} representing the values of the map. + */ + @BetaApi + public final Expression mapValues() { + return mapValues(this); + } + + /** + * Creates an expression that returns the entries of this map expression as an array of maps, + * where each map contains a "k" property for the key and a "v" property for the value. + * + *

While the backend generally preserves insertion order, relying on the order of the output + * array is not guaranteed and should be avoided. + * + * @return A new {@link Expression} representing the entries of the map. + */ + @BetaApi + public final Expression mapEntries() { + return mapEntries(this); + } + /** * Creates an expression that reverses this expression, which must be a string, blob, or array. * diff --git a/google-cloud-firestore/src/test/java/com/google/cloud/firestore/it/ITPipelineTest.java b/google-cloud-firestore/src/test/java/com/google/cloud/firestore/it/ITPipelineTest.java index 5f810332f..9c445a720 100644 --- a/google-cloud-firestore/src/test/java/com/google/cloud/firestore/it/ITPipelineTest.java +++ b/google-cloud-firestore/src/test/java/com/google/cloud/firestore/it/ITPipelineTest.java @@ -107,6 +107,7 @@ import com.google.common.collect.ImmutableMap; import com.google.common.collect.Lists; import java.util.Date; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.concurrent.ExecutionException; @@ -1377,6 +1378,205 @@ public void testMapGet() throws Exception { map("hugoAward", true, "title", "Dune"))); } + @Test + public void testMapSet() throws Exception { + Map docData = new HashMap<>(); + docData.put("existingField", ImmutableMap.of("foo", 1L)); + + Pipeline.Snapshot results = + firestore + .pipeline() + .collection(collection.getPath()) + .replaceWith(Expression.map(docData)) + .limit(1) + .select( + Expression.mapSet("existingField", "bar", 2).as("modifiedField"), + Expression.mapSet(Expression.map(ImmutableMap.of()), "a", 1).as("simple"), + Expression.mapSet(Expression.map(ImmutableMap.of("a", 1)), "b", 2).as("add"), + Expression.mapSet(Expression.map(ImmutableMap.of("a", 1)), "a", 2).as("overwrite"), + Expression.mapSet(Expression.map(ImmutableMap.of("a", 1, "b", 2)), "a", 3, "c", 4) + .as("multi"), + Expression.mapSet( + Expression.map(ImmutableMap.of("a", 1)), "a", field("non_existent")) + .as("remove"), + Expression.mapSet(Expression.map(ImmutableMap.of("a", 1)), "b", null).as("setNull"), + Expression.mapSet( + Expression.map(ImmutableMap.of("a", ImmutableMap.of("b", 1))), "a.b", 2) + .as("setDotted"), + Expression.mapSet(Expression.map(ImmutableMap.of()), "", "empty").as("setEmptyKey"), + Expression.mapSet( + Expression.map(ImmutableMap.of("a", 1)), + "b", + Expression.add(constant(1), constant(2))) + .as("setExprVal"), + Expression.mapSet( + Expression.map(ImmutableMap.of()), "obj", ImmutableMap.of("hidden", true)) + .as("setNestedMap"), + Expression.mapSet(Expression.map(ImmutableMap.of()), "~!@#$%^&*()_+", "special") + .as("setSpecialChars"), + field("existingField").mapSet("instanceKey", 100).as("instanceSetField"), + Expression.map(ImmutableMap.of("x", 1)) + .mapSet(constant("y"), constant(2)) + .as("instanceSetConstant")) + .execute() + .get(); + + List resultList = results.getResults(); + assertThat(resultList).isNotEmpty(); + Map data = resultList.get(0).getData(); + + assertThat((Map) data.get("modifiedField")).containsExactly("foo", 1L, "bar", 2L); + assertThat((Map) data.get("simple")).containsExactly("a", 1L); + assertThat((Map) data.get("add")).containsExactly("a", 1L, "b", 2L); + assertThat((Map) data.get("overwrite")).containsExactly("a", 2L); + assertThat((Map) data.get("multi")).containsExactly("a", 3L, "b", 2L, "c", 4L); + assertThat((Map) data.get("remove")).isEmpty(); + assertThat((Map) data.get("setNull")).containsExactly("a", 1L, "b", null); + + Map setDotted = (Map) data.get("setDotted"); + assertThat(setDotted).containsEntry("a.b", 2L); + assertThat((Map) setDotted.get("a")).containsExactly("b", 1L); + + assertThat((Map) data.get("setEmptyKey")).containsExactly("", "empty"); + assertThat((Map) data.get("setExprVal")).containsExactly("a", 1L, "b", 3L); + assertThat((Map) data.get("setNestedMap")) + .isEqualTo(ImmutableMap.of("obj", ImmutableMap.of("hidden", true))); + assertThat((Map) data.get("setSpecialChars")).containsExactly("~!@#$%^&*()_+", "special"); + + assertThat((Map) data.get("instanceSetField")) + .containsExactly("foo", 1L, "instanceKey", 100L); + assertThat((Map) data.get("instanceSetConstant")).containsExactly("x", 1L, "y", 2L); + } + + @Test + public void testMapKeys() throws Exception { + Map docData = new HashMap<>(); + docData.put("existingField", ImmutableMap.of("foo", 1L)); + + Pipeline.Snapshot results = + firestore + .pipeline() + .collection(collection.getPath()) + .replaceWith(Expression.map(docData)) + .limit(1) + .select( + Expression.mapKeys("existingField").as("existingKeys"), + Expression.mapKeys(Expression.map(ImmutableMap.of("a", 1, "b", 2))).as("keys"), + Expression.mapKeys(Expression.map(ImmutableMap.of())).as("empty_keys"), + Expression.mapKeys( + Expression.map(ImmutableMap.of("a", ImmutableMap.of("nested", true)))) + .as("nested_keys"), + field("existingField").mapKeys().as("instanceExistingKeys"), + Expression.map(ImmutableMap.of("x", 10, "y", 20)).mapKeys().as("instanceKeys")) + .execute() + .get(); + + List resultList = results.getResults(); + assertThat(resultList).isNotEmpty(); + Map data = resultList.get(0).getData(); + + assertThat((List) data.get("existingKeys")).containsExactly("foo"); + assertThat((List) data.get("keys")).containsExactly("a", "b"); + assertThat((List) data.get("empty_keys")).isEmpty(); + assertThat((List) data.get("nested_keys")).containsExactly("a"); + + assertThat((List) data.get("instanceExistingKeys")).containsExactly("foo"); + assertThat((List) data.get("instanceKeys")).containsExactly("x", "y"); + } + + @Test + public void testMapValues() throws Exception { + Map docData = new HashMap<>(); + docData.put("existingField", ImmutableMap.of("foo", 1L)); + + Pipeline.Snapshot results = + firestore + .pipeline() + .collection(collection.getPath()) + .replaceWith(Expression.map(docData)) + .limit(1) + .select( + Expression.mapValues("existingField").as("existingValues"), + Expression.mapValues(Expression.map(ImmutableMap.of("a", 1, "b", 2))).as("values"), + Expression.mapValues(Expression.map(ImmutableMap.of())).as("empty_values"), + Expression.mapValues( + Expression.map(ImmutableMap.of("a", ImmutableMap.of("nested", true)))) + .as("nested_values"), + field("existingField").mapValues().as("instanceExistingValues"), + Expression.map(ImmutableMap.of("x", 10, "y", 20)).mapValues().as("instanceValues")) + .execute() + .get(); + + List resultList = results.getResults(); + assertThat(resultList).isNotEmpty(); + Map data = resultList.get(0).getData(); + + assertThat((List) data.get("existingValues")).containsExactly(1L); + assertThat((List) data.get("values")).containsExactly(1L, 2L); + assertThat((List) data.get("empty_values")).isEmpty(); + assertThat((List) data.get("nested_values")) + .containsExactly(ImmutableMap.of("nested", true)); + + assertThat((List) data.get("instanceExistingValues")).containsExactly(1L); + assertThat((List) data.get("instanceValues")).containsExactly(10L, 20L); + } + + @Test + public void testMapEntries() throws Exception { + Map docData = new HashMap<>(); + docData.put("existingField", ImmutableMap.of("foo", 1L)); + + Pipeline.Snapshot results = + firestore + .pipeline() + .collection(collection.getPath()) + .replaceWith(Expression.map(docData)) + .limit(1) + .select( + Expression.mapEntries("existingField").as("existingEntries"), + Expression.mapEntries(Expression.map(ImmutableMap.of("a", 1, "b", 2))) + .as("entries"), + Expression.mapEntries(Expression.map(ImmutableMap.of())).as("empty_entries"), + Expression.mapEntries( + Expression.map(ImmutableMap.of("a", ImmutableMap.of("nested", true)))) + .as("nested_entries"), + field("existingField").mapEntries().as("instanceExistingEntries"), + Expression.map(ImmutableMap.of("x", 10, "y", 20)) + .mapEntries() + .as("instanceEntries")) + .execute() + .get(); + + List resultList = results.getResults(); + assertThat(resultList).isNotEmpty(); + Map data = resultList.get(0).getData(); + + assertThat((List) data.get("existingEntries")) + .containsExactly(ImmutableMap.of("k", "foo", "v", 1L)); + + @SuppressWarnings("unchecked") + List> entries = (List>) data.get("entries"); + assertThat(entries).hasSize(2); + + // Map entry order is not guaranteed, so we check containment instead of strict ordering + assertThat(entries).contains(ImmutableMap.of("k", "a", "v", 1L)); + assertThat(entries).contains(ImmutableMap.of("k", "b", "v", 2L)); + + assertThat((List) data.get("empty_entries")).isEmpty(); + assertThat((List) data.get("nested_entries")) + .containsExactly(ImmutableMap.of("k", "a", "v", ImmutableMap.of("nested", true))); + + assertThat((List) data.get("instanceExistingEntries")) + .containsExactly(ImmutableMap.of("k", "foo", "v", 1L)); + + @SuppressWarnings("unchecked") + List> instanceEntries = + (List>) data.get("instanceEntries"); + assertThat(instanceEntries).hasSize(2); + assertThat(instanceEntries).contains(ImmutableMap.of("k", "x", "v", 10L)); + assertThat(instanceEntries).contains(ImmutableMap.of("k", "y", "v", 20L)); + } + @Test public void testDataManipulationExpressions() throws Exception { List results = diff --git a/samples/preview-snippets/src/main/java/com/example/firestore/PipelineSnippets.java b/samples/preview-snippets/src/main/java/com/example/firestore/PipelineSnippets.java index cf6efe72d..5731a964a 100644 --- a/samples/preview-snippets/src/main/java/com/example/firestore/PipelineSnippets.java +++ b/samples/preview-snippets/src/main/java/com/example/firestore/PipelineSnippets.java @@ -1177,6 +1177,19 @@ void mapGetFunction() throws ExecutionException, InterruptedException { System.out.println(result.getResults()); } + void mapSetFunction() throws ExecutionException, InterruptedException { + // [START map_get] + Pipeline.Snapshot result = + firestore + .pipeline() + .collection("books") + .select(mapSet(field("awards"), "pulitzer").as("awards")) + .execute() + .get(); + // [END map_get] + System.out.println(result.getResults()); + } + void byteLengthFunction() throws ExecutionException, InterruptedException { // [START byte_length] Pipeline.Snapshot result =