diff --git a/google-cloud-firestore/src/main/java/com/google/cloud/firestore/pipeline/expressions/AggregateFunction.java b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/pipeline/expressions/AggregateFunction.java index d3a3bba36..1cab69389 100644 --- a/google-cloud-firestore/src/main/java/com/google/cloud/firestore/pipeline/expressions/AggregateFunction.java +++ b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/pipeline/expressions/AggregateFunction.java @@ -214,6 +214,111 @@ public static AggregateFunction maximum(Expression expression) { return new AggregateFunction("maximum", expression); } + /** + * Creates an aggregation that finds the first value of a field across multiple stage inputs. + * + * @param fieldName The name of the field to find the first value of. + * @return A new {@link AggregateFunction} representing the first aggregation. + */ + @BetaApi + public static AggregateFunction first(String fieldName) { + return new AggregateFunction("first", fieldName); + } + + /** + * Creates an aggregation that finds the first value of an expression across multiple stage + * inputs. + * + * @param expression The expression to find the first value of. + * @return A new {@link AggregateFunction} representing the first aggregation. + */ + @BetaApi + public static AggregateFunction first(Expression expression) { + return new AggregateFunction("first", expression); + } + + /** + * Creates an aggregation that finds the last value of a field across multiple stage inputs. + * + * @param fieldName The name of the field to find the last value of. + * @return A new {@link AggregateFunction} representing the last aggregation. + */ + @BetaApi + public static AggregateFunction last(String fieldName) { + return new AggregateFunction("last", fieldName); + } + + /** + * Creates an aggregation that finds the last value of an expression across multiple stage inputs. + * + * @param expression The expression to find the last value of. + * @return A new {@link AggregateFunction} representing the last aggregation. + */ + @BetaApi + public static AggregateFunction last(Expression expression) { + return new AggregateFunction("last", expression); + } + + /** + * Creates an aggregation that collects all values of a field across multiple stage inputs into an + * array. + * + *

If the expression resolves to an absent value, it is converted to `null`. The order of + * elements in the output array is not stable and shouldn't be relied upon. + * + * @param fieldName The name of the field to collect values from. + * @return A new {@link AggregateFunction} representing the array_agg aggregation. + */ + @BetaApi + public static AggregateFunction arrayAgg(String fieldName) { + return new AggregateFunction("array_agg", fieldName); + } + + /** + * Creates an aggregation that collects all values of an expression across multiple stage inputs + * into an array. + * + *

If the expression resolves to an absent value, it is converted to `null`. The order of + * elements in the output array is not stable and shouldn't be relied upon. + * + * @param expression The expression to collect values from. + * @return A new {@link AggregateFunction} representing the array_agg aggregation. + */ + @BetaApi + public static AggregateFunction arrayAgg(Expression expression) { + return new AggregateFunction("array_agg", expression); + } + + /** + * Creates an aggregation that collects all distinct values of a field across multiple stage + * inputs into an array. + * + *

If the expression resolves to an absent value, it is converted to `null`. The order of + * elements in the output array is not stable and shouldn't be relied upon. + * + * @param fieldName The name of the field to collect values from. + * @return A new {@link AggregateFunction} representing the array_agg_distinct aggregation. + */ + @BetaApi + public static AggregateFunction arrayAggDistinct(String fieldName) { + return new AggregateFunction("array_agg_distinct", fieldName); + } + + /** + * Creates an aggregation that collects all distinct values of an expression across multiple stage + * inputs into an array. + * + *

If the expression resolves to an absent value, it is converted to `null`. The order of + * elements in the output array is not stable and shouldn't be relied upon. + * + * @param expression The expression to collect values from. + * @return A new {@link AggregateFunction} representing the array_agg_distinct aggregation. + */ + @BetaApi + public static AggregateFunction arrayAggDistinct(Expression expression) { + return new AggregateFunction("array_agg_distinct", expression); + } + /** * Assigns an alias to this aggregate. * 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 14f2bd9ab..14c310743 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 @@ -3213,6 +3213,94 @@ public static Expression roundToPrecision(String numericField, Expression decima return roundToPrecision(field(numericField), decimalPlace); } + /** + * Creates an expression that returns a random double between 0.0 and 1.0 but not including 1.0. + * + * @return A new {@link Expression} representing a random double result from the rand operation. + */ + @BetaApi + public static Expression rand() { + return new FunctionExpression("rand", ImmutableList.of()); + } + + /** + * Creates an expression that truncates {@code numericExpr} to an integer. + * + * @param numericExpr An expression that returns number when evaluated. + * @return A new {@link Expression} representing the trunc operation. + */ + @BetaApi + public static Expression trunc(Expression numericExpr) { + return new FunctionExpression("trunc", ImmutableList.of(numericExpr)); + } + + /** + * Creates an expression that truncates {@code numericField} to an integer. + * + * @param numericField Name of field that returns number when evaluated. + * @return A new {@link Expression} representing the trunc operation. + */ + @BetaApi + public static Expression trunc(String numericField) { + return trunc(field(numericField)); + } + + /** + * Creates an expression that truncates {@code numericExpr} to {@code decimalPlace} decimal places + * if {@code decimalPlace} is positive, truncates digits to the left of the decimal point if + * {@code decimalPlace} is negative. + * + * @param numericExpr An expression that returns number when evaluated. + * @param decimalPlace The number of decimal places to truncate. + * @return A new {@link Expression} representing the trunc operation. + */ + @BetaApi + public static Expression truncToPrecision(Expression numericExpr, int decimalPlace) { + return new FunctionExpression("trunc", ImmutableList.of(numericExpr, constant(decimalPlace))); + } + + /** + * Creates an expression that truncates {@code numericField} to {@code decimalPlace} decimal + * places if {@code decimalPlace} is positive, truncates digits to the left of the decimal point + * if {@code decimalPlace} is negative. + * + * @param numericField Name of field that returns number when evaluated. + * @param decimalPlace The number of decimal places to truncate. + * @return A new {@link Expression} representing the trunc operation. + */ + @BetaApi + public static Expression truncToPrecision(String numericField, int decimalPlace) { + return truncToPrecision(field(numericField), decimalPlace); + } + + /** + * Creates an expression that truncates {@code numericExpr} to {@code decimalPlace} decimal places + * if {@code decimalPlace} is positive, truncates digits to the left of the decimal point if + * {@code decimalPlace} is negative. + * + * @param numericExpr An expression that returns number when evaluated. + * @param decimalPlace The number of decimal places to truncate. + * @return A new {@link Expression} representing the trunc operation. + */ + @BetaApi + public static Expression truncToPrecision(Expression numericExpr, Expression decimalPlace) { + return new FunctionExpression("trunc", ImmutableList.of(numericExpr, decimalPlace)); + } + + /** + * Creates an expression that truncates {@code numericField} to {@code decimalPlace} decimal + * places if {@code decimalPlace} is positive, truncates digits to the left of the decimal point + * if {@code decimalPlace} is negative. + * + * @param numericField Name of field that returns number when evaluated. + * @param decimalPlace The number of decimal places to truncate. + * @return A new {@link Expression} representing the trunc operation. + */ + @BetaApi + public static Expression truncToPrecision(String numericField, Expression decimalPlace) { + return truncToPrecision(field(numericField), decimalPlace); + } + /** * Creates an expression that returns the smallest integer that isn't less than {@code * numericExpr}. @@ -3686,6 +3774,42 @@ public final Expression roundToPrecision(Expression decimalPlace) { return roundToPrecision(this, decimalPlace); } + /** + * Creates an expression that truncates this numeric expression to an integer. + * + * @return A new {@link Expression} representing the trunc operation. + */ + @BetaApi + public final Expression trunc() { + return trunc(this); + } + + /** + * Creates an expression that truncates this numeric expression to {@code decimalPlace} decimal + * places if {@code decimalPlace} is positive, truncates digits to the left of the decimal point + * if {@code decimalPlace} is negative. + * + * @param decimalPlace The number of decimal places to truncate. + * @return A new {@link Expression} representing the trunc operation. + */ + @BetaApi + public final Expression truncToPrecision(int decimalPlace) { + return truncToPrecision(this, decimalPlace); + } + + /** + * Creates an expression that truncates this numeric expression to {@code decimalPlace} decimal + * places if {@code decimalPlace} is positive, truncates digits to the left of the decimal point + * if {@code decimalPlace} is negative. + * + * @param decimalPlace The number of decimal places to truncate. + * @return A new {@link Expression} representing the trunc operation. + */ + @BetaApi + public final Expression truncToPrecision(Expression decimalPlace) { + return truncToPrecision(this, decimalPlace); + } + /** * Creates an expression that returns the smallest integer that isn't less than this numeric * expression. @@ -4303,6 +4427,56 @@ public final AggregateFunction countDistinct() { return AggregateFunction.countDistinct(this); } + /** + * Creates an aggregation that finds the first value of this expression across multiple stage + * inputs. + * + * @return A new {@link AggregateFunction} representing the first aggregation. + */ + @BetaApi + public final AggregateFunction first() { + return AggregateFunction.first(this); + } + + /** + * Creates an aggregation that finds the last value of this expression across multiple stage + * inputs. + * + * @return A new {@link AggregateFunction} representing the last aggregation. + */ + @BetaApi + public final AggregateFunction last() { + return AggregateFunction.last(this); + } + + /** + * Creates an aggregation that collects all values of this expression across multiple stage inputs + * into an array. + * + *

If the expression resolves to an absent value, it is converted to `null`. The order of + * elements in the output array is not stable and shouldn't be relied upon. + * + * @return A new {@link AggregateFunction} representing the array_agg aggregation. + */ + @BetaApi + public final AggregateFunction arrayAgg() { + return AggregateFunction.arrayAgg(this); + } + + /** + * Creates an aggregation that collects all distinct values of this expression across multiple + * stage inputs into an array. + * + *

If the expression resolves to an absent value, it is converted to `null`. The order of + * elements in the output array is not stable and shouldn't be relied upon. + * + * @return A new {@link AggregateFunction} representing the array_agg_distinct aggregation. + */ + @BetaApi + public final AggregateFunction arrayAggDistinct() { + return AggregateFunction.arrayAggDistinct(this); + } + /** * Create an {@link Ordering} that sorts documents in ascending order based on value of this * expression 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..8dc00b03f 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 @@ -19,10 +19,14 @@ import static com.google.cloud.firestore.FieldValue.vector; import static com.google.cloud.firestore.it.ITQueryTest.map; import static com.google.cloud.firestore.it.TestHelper.isRunningAgainstFirestoreEmulator; +import static com.google.cloud.firestore.pipeline.expressions.AggregateFunction.arrayAgg; +import static com.google.cloud.firestore.pipeline.expressions.AggregateFunction.arrayAggDistinct; import static com.google.cloud.firestore.pipeline.expressions.AggregateFunction.count; import static com.google.cloud.firestore.pipeline.expressions.AggregateFunction.countAll; import static com.google.cloud.firestore.pipeline.expressions.AggregateFunction.countDistinct; import static com.google.cloud.firestore.pipeline.expressions.AggregateFunction.countIf; +import static com.google.cloud.firestore.pipeline.expressions.AggregateFunction.first; +import static com.google.cloud.firestore.pipeline.expressions.AggregateFunction.last; import static com.google.cloud.firestore.pipeline.expressions.AggregateFunction.sum; import static com.google.cloud.firestore.pipeline.expressions.Expression.add; import static com.google.cloud.firestore.pipeline.expressions.Expression.and; @@ -56,6 +60,7 @@ import static com.google.cloud.firestore.pipeline.expressions.Expression.nullValue; import static com.google.cloud.firestore.pipeline.expressions.Expression.or; import static com.google.cloud.firestore.pipeline.expressions.Expression.pow; +import static com.google.cloud.firestore.pipeline.expressions.Expression.rand; import static com.google.cloud.firestore.pipeline.expressions.Expression.regexMatch; import static com.google.cloud.firestore.pipeline.expressions.Expression.round; import static com.google.cloud.firestore.pipeline.expressions.Expression.sqrt; @@ -67,6 +72,8 @@ import static com.google.cloud.firestore.pipeline.expressions.Expression.timestampToUnixMicros; import static com.google.cloud.firestore.pipeline.expressions.Expression.timestampToUnixMillis; import static com.google.cloud.firestore.pipeline.expressions.Expression.timestampToUnixSeconds; +import static com.google.cloud.firestore.pipeline.expressions.Expression.trunc; +import static com.google.cloud.firestore.pipeline.expressions.Expression.truncToPrecision; import static com.google.cloud.firestore.pipeline.expressions.Expression.unixMicrosToTimestamp; import static com.google.cloud.firestore.pipeline.expressions.Expression.unixMillisToTimestamp; import static com.google.cloud.firestore.pipeline.expressions.Expression.unixSecondsToTimestamp; @@ -579,6 +586,132 @@ public void testMinMax() throws Exception { "min_published", 1813L))); } + @Test + public void testFirstAndLastAccumulators() throws Exception { + List results = + firestore + .pipeline() + .createFrom(collection) + .where(field("published").greaterThan(0)) + .sort(field("published").ascending()) + .aggregate( + first("rating").as("firstBookRating"), + first("title").as("firstBookTitle"), + last("rating").as("lastBookRating"), + last("title").as("lastBookTitle")) + .execute() + .get() + .getResults(); + + Map result = data(results).get(0); + assertThat(result.get("firstBookRating")).isEqualTo(4.5); + assertThat(result.get("firstBookTitle")).isEqualTo("Pride and Prejudice"); + assertThat(result.get("lastBookRating")).isEqualTo(4.1); + assertThat(result.get("lastBookTitle")).isEqualTo("The Handmaid's Tale"); + } + + @Test + public void testFirstAndLastAccumulatorsWithInstanceMethod() throws Exception { + List results = + firestore + .pipeline() + .createFrom(collection) + .where(field("published").greaterThan(0)) + .sort(field("published").ascending()) + .aggregate( + field("rating").first().as("firstBookRating"), + field("title").first().as("firstBookTitle"), + field("rating").last().as("lastBookRating"), + field("title").last().as("lastBookTitle")) + .execute() + .get() + .getResults(); + + Map result = data(results).get(0); + assertThat(result.get("firstBookRating")).isEqualTo(4.5); + assertThat(result.get("firstBookTitle")).isEqualTo("Pride and Prejudice"); + assertThat(result.get("lastBookRating")).isEqualTo(4.1); + assertThat(result.get("lastBookTitle")).isEqualTo("The Handmaid's Tale"); + } + + @Test + public void testArrayAggAccumulators() throws Exception { + List results = + firestore + .pipeline() + .createFrom(collection) + .where(field("published").greaterThan(0)) + .sort(field("published").ascending()) + .aggregate(arrayAgg("rating").as("allRatings")) + .execute() + .get() + .getResults(); + + Map result = data(results).get(0); + assertThat((List) result.get("allRatings")) + .containsExactly(4.5, 4.3, 4.0, 4.2, 4.7, 4.2, 4.6, 4.3, 4.2, 4.1) + .inOrder(); + } + + @Test + public void testArrayAggAccumulatorsWithInstanceMethod() throws Exception { + List results = + firestore + .pipeline() + .createFrom(collection) + .where(field("published").greaterThan(0)) + .sort(field("published").ascending()) + .aggregate(field("rating").arrayAgg().as("allRatings")) + .execute() + .get() + .getResults(); + + Map result = data(results).get(0); + assertThat((List) result.get("allRatings")) + .containsExactly(4.5, 4.3, 4.0, 4.2, 4.7, 4.2, 4.6, 4.3, 4.2, 4.1) + .inOrder(); + } + + @Test + public void testArrayAggDistinctAccumulators() throws Exception { + List results = + firestore + .pipeline() + .createFrom(collection) + .where(field("published").greaterThan(0)) + .aggregate(arrayAggDistinct("rating").as("allDistinctRatings")) + .execute() + .get() + .getResults(); + + Map result = data(results).get(0); + List distinctRatings = (List) result.get("allDistinctRatings"); + List sortedRatings = + distinctRatings.stream().map(o -> (Double) o).sorted().collect(Collectors.toList()); + + assertThat(sortedRatings).containsExactly(4.0, 4.1, 4.2, 4.3, 4.5, 4.6, 4.7).inOrder(); + } + + @Test + public void testArrayAggDistinctAccumulatorsWithInstanceMethod() throws Exception { + List results = + firestore + .pipeline() + .createFrom(collection) + .where(field("published").greaterThan(0)) + .aggregate(field("rating").arrayAggDistinct().as("allDistinctRatings")) + .execute() + .get() + .getResults(); + + Map result = data(results).get(0); + List distinctRatings = (List) result.get("allDistinctRatings"); + List sortedRatings = + distinctRatings.stream().map(o -> (Double) o).sorted().collect(Collectors.toList()); + + assertThat(sortedRatings).containsExactly(4.0, 4.1, 4.2, 4.3, 4.5, 4.6, 4.7).inOrder(); + } + @Test public void selectSpecificFields() throws Exception { List results = @@ -1552,6 +1685,131 @@ public void testAdvancedMathExpressions() throws Exception { assertThat((Double) result.get("log10_rating")).isWithin(0.00001).of(0.67209); } + @Test + public void testRand() throws Exception { + assumeFalse( + "Rand is not supported against the emulator.", + isRunningAgainstFirestoreEmulator(firestore)); + + List results = + firestore + .pipeline() + .createFrom(collection) + .select(rand().as("randomNumber")) + .limit(1) + .execute() + .get() + .getResults(); + + assertThat(results).hasSize(1); + Object randomNumber = results.get(0).getData().get("randomNumber"); + assertThat(randomNumber).isInstanceOf(Double.class); + assertThat((Double) randomNumber).isAtLeast(0.0); + assertThat((Double) randomNumber).isLessThan(1.0); + } + + @Test + public void testTrunc() throws Exception { + assumeFalse( + "Trunc is not supported against the emulator.", + isRunningAgainstFirestoreEmulator(firestore)); + + List results = + firestore + .pipeline() + .createFrom(collection) + .where(field("title").equal("Pride and Prejudice")) + .limit(1) + .select(trunc("rating").as("truncatedRating")) + .execute() + .get() + .getResults(); + + Map result = data(results).get(0); + assertThat(result.get("truncatedRating")).isEqualTo(4.0); + } + + @Test + public void testTruncWithInstanceMethod() throws Exception { + assumeFalse( + "Trunc is not supported against the emulator.", + isRunningAgainstFirestoreEmulator(firestore)); + + List results = + firestore + .pipeline() + .createFrom(collection) + .where(field("title").equal("Pride and Prejudice")) + .limit(1) + .select(field("rating").trunc().as("truncatedRating")) + .execute() + .get() + .getResults(); + + Map result = data(results).get(0); + assertThat(result.get("truncatedRating")).isEqualTo(4.0); + } + + @Test + public void testTruncToPrecision() throws Exception { + assumeFalse( + "Trunc is not supported against the emulator.", + isRunningAgainstFirestoreEmulator(firestore)); + + List results = + firestore + .pipeline() + .createFrom(collection) + .limit(1) + .select( + truncToPrecision(constant(4.123456), 0).as("p0"), + truncToPrecision(constant(4.123456), 1).as("p1"), + truncToPrecision(constant(4.123456), 2).as("p2"), + truncToPrecision(constant(4.123456), 4).as("p4")) + .execute() + .get() + .getResults(); + + assertThat(data(results)) + .isEqualTo( + Lists.newArrayList( + map( + "p0", 4.0, + "p1", 4.1, + "p2", 4.12, + "p4", 4.1234))); + } + + @Test + public void testTruncToPrecisionWithInstanceMethod() throws Exception { + assumeFalse( + "Trunc is not supported against the emulator.", + isRunningAgainstFirestoreEmulator(firestore)); + + List results = + firestore + .pipeline() + .createFrom(collection) + .limit(1) + .select( + constant(4.123456).truncToPrecision(0).as("p0"), + constant(4.123456).truncToPrecision(1).as("p1"), + constant(4.123456).truncToPrecision(constant(2)).as("p2"), + constant(4.123456).truncToPrecision(4).as("p4")) + .execute() + .get() + .getResults(); + + assertThat(data(results)) + .isEqualTo( + Lists.newArrayList( + map( + "p0", 4.0, + "p1", 4.1, + "p2", 4.12, + "p4", 4.1234))); + } + @Test public void testConcat() throws Exception { // String concat