From 086afe338dba0fc2b24cbc193f07acdb3ce9d36c Mon Sep 17 00:00:00 2001 From: cherylEnkidu Date: Wed, 11 Feb 2026 15:00:34 -0500 Subject: [PATCH 01/53] add subquery support in pipeline --- .../com/google/firebase/firestore/Pipeline.kt | 191 ++++++------------ .../firestore/pipeline/expressions.kt | 79 ++++++-- .../firebase/firestore/pipeline/stage.kt | 44 +++- .../proto/google/firestore/v1/document.proto | 11 +- 4 files changed, 172 insertions(+), 153 deletions(-) diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/Pipeline.kt b/firebase-firestore/src/main/java/com/google/firebase/firestore/Pipeline.kt index 2a7ac513c35..ea875ff14ed 100644 --- a/firebase-firestore/src/main/java/com/google/firebase/firestore/Pipeline.kt +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/Pipeline.kt @@ -61,6 +61,9 @@ import com.google.firebase.firestore.pipeline.UnnestStage import com.google.firebase.firestore.pipeline.WhereStage import com.google.firebase.firestore.remote.RemoteSerializer import com.google.firebase.firestore.util.Logger +import com.google.firebase.firestore.pipeline.DefineStage +import com.google.firebase.firestore.pipeline.evaluation.notImplemented +import com.google.firebase.firestore.pipeline.SubcollectionSource import com.google.firestore.v1.ExecutePipelineRequest import com.google.firestore.v1.Pipeline as ProtoPipeline import com.google.firestore.v1.StructuredPipeline @@ -84,8 +87,8 @@ import com.google.firestore.v1.Value @Beta class Pipeline internal constructor( - private val firestore: FirebaseFirestore, - private val userDataReader: UserDataReader, + private val firestore: FirebaseFirestore?, + private val userDataReader: UserDataReader?, private val stages: List> ) { class ExecuteOptions private constructor(options: InternalOptions) : @@ -133,8 +136,8 @@ internal constructor( } internal constructor( - firestore: FirebaseFirestore, - userDataReader: UserDataReader, + firestore: FirebaseFirestore?, + userDataReader: UserDataReader?, stage: Stage<*> ) : this(firestore, userDataReader, listOf(stage)) @@ -142,24 +145,26 @@ internal constructor( return Pipeline(firestore, userDataReader, stages.plus(stage)) } - private fun toStructuredPipelineProto(options: InternalOptions?): StructuredPipeline { + private fun toStructuredPipelineProto(options: InternalOptions?, userDataReader: UserDataReader): StructuredPipeline { val builder = StructuredPipeline.newBuilder() - builder.pipeline = toPipelineProto() + builder.pipeline = toPipelineProto(userDataReader) options?.forEach(builder::putOptions) return builder.build() } - internal fun toPipelineProto(): ProtoPipeline = - ProtoPipeline.newBuilder().addAllStages(stages.map { it.toProtoStage(userDataReader) }).build() - - private fun toExecutePipelineRequest(options: InternalOptions?): ExecutePipelineRequest { - val database = firestore!!.databaseId - val builder = ExecutePipelineRequest.newBuilder() - builder.database = "projects/${database.projectId}/databases/${database.databaseId}" - builder.structuredPipeline = toStructuredPipelineProto(options) - return builder.build() + internal fun toPipelineProto(userDataReader: UserDataReader): ProtoPipeline { + return ProtoPipeline.newBuilder().addAllStages(stages.map { it.toProtoStage(userDataReader) }).build() } + private fun toExecutePipelineRequest(options: InternalOptions?): ExecutePipelineRequest { + checkNotNull(firestore) { "Cannot execute pipeline without a Firestore instance" } + val database = firestore!!.databaseId + val builder = ExecutePipelineRequest.newBuilder() + builder.database = "projects/${database.projectId}/databases/${database.databaseId}" + builder.structuredPipeline = toStructuredPipelineProto(options, firestore.userDataReader) + return builder.build() + } + /** * Executes this pipeline and returns the results as a [Task] of [Snapshot]. * @@ -897,6 +902,49 @@ internal constructor( * @return A new [Pipeline] object with this stage appended to the stage list. */ fun unnest(unnestStage: UnnestStage): Pipeline = append(unnestStage) + + /** + * Binds the given expressions to variables in the pipeline scope. + * + * @param variables One or more variables to bind. + * @return The [Pipeline] with the bound variables. + */ + fun define(vararg variables: AliasedExpression): Pipeline { + return append(DefineStage(variables)) + } + + /** + * Converts this pipeline to an expression that evaluates to an array of results. + * + * @return An [Expression] that executes this pipeline and returns the results as a list. + */ + fun toArrayExpression(): Expression { + return FunctionExpression("array", notImplemented, Expression.toExprOrConstant(this)) + } + + /** + * Converts this pipeline to an expression that evaluates to a scalar result. + * The pipeline must return exactly one document with one field, or be an aggregation. + * + * @return An [Expression] that executes this pipeline and returns a single value. + */ + fun toScalarExpression(): Expression { + return FunctionExpression("scalar", notImplemented, Expression.toExprOrConstant(this)) + } + + companion object { + /** + * Creates a pipeline that processes the documents in the specified subcollection of the current + * document. + * + * @param path The relative path to the subcollection. + * @return A new [Pipeline] scoped to the subcollection. + */ + @JvmStatic + fun subcollection(path: String): Pipeline { + return Pipeline(null, null, SubcollectionSource(path)) + } + } } /** Start of a Firestore Pipeline */ @@ -1047,119 +1095,6 @@ class PipelineSource internal constructor(private val firestore: FirebaseFiresto } } -/** - * Represents the results of a Pipeline query, including the data and metadata. It is usually - * accessed via [Pipeline.Snapshot]. - */ -@Beta -class PipelineResult -internal constructor( - private val userDataWriter: UserDataWriter, - ref: DocumentReference?, - private val fields: Map, - createTime: Timestamp?, - updateTime: Timestamp?, -) { - - internal constructor( - document: Document, - serverTimestampBehavior: DocumentSnapshot.ServerTimestampBehavior, - firestore: FirebaseFirestore - ) : this( - UserDataWriter(firestore, serverTimestampBehavior), - DocumentReference(document.key, firestore), - document.data.fieldsMap, - document.createTime?.timestamp, - document.version.timestamp - ) - - /** The time the document was created. Null if this result is not a document. */ - val createTime: Timestamp? = createTime - - /** - * The time the document was last updated (at the time the snapshot was generated). Null if this - * result is not a document. - */ - val updateTime: Timestamp? = updateTime - - /** - * The reference to the document, if the query returns the document id for a document. The name - * field will be returned by default if querying a document. - * - * Document ids will not be returned if certain pipeline stages omit the document id. For example, - * [Pipeline.select], [Pipeline.removeFields] and [Pipeline.aggregate] can omit the document id. - * - * @return [DocumentReference] Reference to the document, if applicable. - */ - val ref: DocumentReference? = ref - - /** - * Returns the ID of the document represented by this result. Returns null if this result is not - * corresponding to a Firestore document. - * - * @return ID of document, if applicable. - */ - fun getId(): String? = ref?.id - - /** - * Retrieves all fields in the result as an object map. - * - * @return Map of field names to objects. - */ - fun getData(): Map = userDataWriter.convertObject(fields) - - private fun extractNestedValue(fieldPath: FieldPath): Value? { - val segments = fieldPath.internalPath.iterator() - if (!segments.hasNext()) { - return Values.encodeValue(fields) - } - val firstSegment = segments.next() - if (!fields.containsKey(firstSegment)) { - return null - } - var value: Value? = fields[firstSegment] - for (segment in segments) { - if (value == null || !value.hasMapValue()) { - return null - } - value = value.mapValue.getFieldsOrDefault(segment, null) - } - return value - } - - /** - * Retrieves the field specified by [field]. - * - * @param field The field path (e.g. "foo" or "foo.bar") to a specific field. - * @return The data at the specified field location or null if no such field exists. - */ - fun get(field: String): Any? = get(FieldPath.fromDotSeparatedPath(field)) - - /** - * Retrieves the field specified by [fieldPath]. - * - * @param fieldPath The field path to a specific field. - * @return The data at the specified field location or null if no such field exists. - */ - fun get(fieldPath: FieldPath): Any? = userDataWriter.convertValue(extractNestedValue(fieldPath)) - - override fun toString() = "PipelineResult{ref=$ref, updateTime=$updateTime}, data=${getData()}" - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - other as PipelineResult - if (ref != other.ref) return false - if (fields != other.fields) return false - return true - } - - override fun hashCode(): Int { - var result = ref?.hashCode() ?: 0 - result = 31 * result + fields.hashCode() - return result - } -} - internal interface PipelineResultObserver { fun onDocument( key: DocumentKey?, diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/expressions.kt b/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/expressions.kt index 053921f5cfd..2d00fc3d5be 100644 --- a/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/expressions.kt +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/expressions.kt @@ -118,6 +118,7 @@ abstract class Expression internal constructor() { .toTypedArray() ) is List<*> -> array(value) + is Pipeline -> PipelineValueExpression(value) else -> null } } @@ -3090,6 +3091,16 @@ abstract class Expression internal constructor() { fun map(elements: Map): Expression = map(elements.flatMap { listOf(constant(it.key), toExprOrConstant(it.value)) }.toTypedArray()) + /** + * Accesses a field/property of the expression (useful when the expression evaluates to a Map or Document). + * + * @param key The key of the field to access. + * @return An [Expression] representing the value of the field. + */ + @JvmStatic + fun getField(expression: Expression, key: String): Expression = + FunctionExpression("field", notImplemented, expression, key) + /** * Accesses a value from a map (object) field using the provided [key]. * @@ -5403,6 +5414,22 @@ abstract class Expression internal constructor() { * @return A new [Expression] representing the documentId operation. */ @JvmStatic fun documentId(docRef: DocumentReference): Expression = documentId(constant(docRef)) + + /** + * Creates an expression that retrieves the value of a variable bound via [Pipeline.define]. + * + * @param name The name of the variable to retrieve. + * @return An [Expression] representing the variable's value. + */ + @JvmStatic fun variable(name: String): Expression = Variable(name) + + /** + * Creates an expression that represents the current document being processed. + * + * @return An [Expression] representing the current document. + */ + @JvmStatic fun currentDocument(): Expression = + FunctionExpression("current_document", notImplemented) } /** @@ -7403,24 +7430,6 @@ abstract class Expression internal constructor() { internal abstract fun evaluateFunction(context: EvaluationContext): EvaluateDocument } -/** Expressions that have an alias are [Selectable] */ -@Beta -abstract class Selectable : Expression() { - internal abstract val alias: String - internal abstract val expr: Expression - - internal companion object { - fun toSelectable(o: Any): Selectable { - return when (o) { - is Selectable -> o - is String -> field(o) - is FieldPath -> field(o) - else -> throw IllegalArgumentException("Unknown Selectable type: $o") - } - } - } -} - /** Represents an expression that will be given the alias in the output document. */ @Beta class AliasedExpression @@ -7578,13 +7587,13 @@ internal constructor( name: String, function: EvaluateFunction, fieldName: String - ) : this(name, function, arrayOf(field(fieldName))) + ) : this(name, function, arrayOf(Expression.field(fieldName))) internal constructor( name: String, function: EvaluateFunction, fieldName: String, vararg params: Any - ) : this(name, function, arrayOf(field(fieldName), *toArrayOfExprOrConstant(params))) + ) : this(name, function, arrayOf(Expression.field(fieldName), *toArrayOfExprOrConstant(params))) override fun toProto(userDataReader: UserDataReader): Value { val builder = ProtoFunction.newBuilder() @@ -7730,13 +7739,13 @@ internal class BooleanFunctionExpression internal constructor(val expr: Expressi name: String, function: EvaluateFunction, fieldName: String - ) : this(name, function, arrayOf(field(fieldName))) + ) : this(name, function, arrayOf(Expression.field(fieldName))) internal constructor( name: String, function: EvaluateFunction, fieldName: String, vararg params: Any - ) : this(name, function, arrayOf(field(fieldName), *Expression.toArrayOfExprOrConstant(params))) + ) : this(name, function, arrayOf(Expression.field(fieldName), *Expression.toArrayOfExprOrConstant(params))) override fun toProto(userDataReader: UserDataReader): Value = expr.toProto(userDataReader) @@ -7880,3 +7889,29 @@ class Ordering internal constructor(val expr: Expression, val dir: Direction) { ) .build() } + +internal class Variable(val name: String) : Expression() { + override fun toProto(userDataReader: UserDataReader): Value = + Value.newBuilder().setVariableReferenceValue(name).build() + override fun evaluateFunction(context: EvaluationContext) = { _: MutableDocument -> + throw NotImplementedError("Variable evaluation not implemented") + } + override fun canonicalId() = "var($name)" + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is Variable) return false + return name == other.name + } + override fun hashCode(): Int = name.hashCode() +} + +private class PipelineValueExpression(val pipeline: Pipeline) : Expression() { + override fun toProto(userDataReader: UserDataReader): Value = + Value.newBuilder().setPipelineValue(pipeline.toPipelineProto(userDataReader)).build() + override fun evaluateFunction(context: EvaluationContext) = { _: MutableDocument -> + throw NotImplementedError("Pipeline evaluation not implemented") + } + override fun canonicalId() = "pipeline(\${pipeline.hashCode()})" + override fun toString() = "Pipeline(...)" +} + diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/stage.kt b/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/stage.kt index 100f8c3a55e..aa449795385 100644 --- a/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/stage.kt +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/stage.kt @@ -398,6 +398,18 @@ internal constructor( documents.asSequence().map(::encodeValue) } +class SubcollectionSource( + internal val path: String, + options: InternalOptions = InternalOptions.EMPTY +) : Stage("subcollection", options) { + + override fun self(options: InternalOptions) = SubcollectionSource(path, options) + + override fun canonicalId(): String = "${name}($path)" + + override fun args(userDataReader: UserDataReader): Sequence = sequenceOf(Values.encodeValue(path)) +} + private fun associateWithoutDuplications( fields: Array, userDataReader: UserDataReader @@ -1192,7 +1204,7 @@ internal constructor( } override fun args(userDataReader: UserDataReader): Sequence = - sequenceOf(Value.newBuilder().setPipelineValue(other.toPipelineProto()).build()) + sequenceOf(Value.newBuilder().setPipelineValue(other.toPipelineProto(userDataReader)).build()) override fun equals(other: Any?): Boolean { if (this === other) return true @@ -1308,3 +1320,33 @@ class UnnestOptions private constructor(options: InternalOptions) : return UnnestOptions(options) } } + +internal class DefineStage( + private val variables: Array, + options: InternalOptions = InternalOptions.EMPTY +) : Stage("define", options) { + + override fun self(options: InternalOptions) = DefineStage(variables, options) + + override fun canonicalId(): String { + TODO("Not yet implemented") + } + + override fun args(userDataReader: UserDataReader): Sequence { + return sequenceOf(encodeValue(associateWithoutDuplications(variables, userDataReader))) + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is DefineStage) return false + if (!variables.contentEquals(other.variables)) return false + if (options != other.options) return false + return true + } + + override fun hashCode(): Int { + var result = variables.contentHashCode() + result = 31 * result + options.hashCode() + return result + } +} diff --git a/firebase-firestore/src/proto/google/firestore/v1/document.proto b/firebase-firestore/src/proto/google/firestore/v1/document.proto index 9947a289a1e..608351dfc60 100644 --- a/firebase-firestore/src/proto/google/firestore/v1/document.proto +++ b/firebase-firestore/src/proto/google/firestore/v1/document.proto @@ -146,6 +146,14 @@ message Value { // allowed to be used on the write path. --) string field_reference_value = 19; + // Pointer to a variable defined elsewhere in a pipeline. + // + // Unlike `field_reference_value` which references a field within a + // document, this refers to a variable, defined in a separate namespace than + // the fields of a document. + // + string variable_reference_value = 22; + // A value that represents an unevaluated expression. // // **Requires:** @@ -260,5 +268,4 @@ message Pipeline { // Ordered list of stages to evaluate. repeated Stage stages = 1; -} - +} \ No newline at end of file From 8229020a3811b0c28b0428f34f6c0362d8844e20 Mon Sep 17 00:00:00 2001 From: cherylEnkidu Date: Wed, 11 Feb 2026 16:19:28 -0500 Subject: [PATCH 02/53] add missing files --- .../firebase/firestore/PipelineResult.kt | 136 ++++++++++++++++++ .../firebase/firestore/pipeline/Selectable.kt | 52 +++++++ .../pipeline/SubqueryPipelineTests.kt | 109 ++++++++++++++ 3 files changed, 297 insertions(+) create mode 100644 firebase-firestore/src/main/java/com/google/firebase/firestore/PipelineResult.kt create mode 100644 firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/Selectable.kt create mode 100644 firebase-firestore/src/test/java/com/google/firebase/firestore/pipeline/SubqueryPipelineTests.kt diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/PipelineResult.kt b/firebase-firestore/src/main/java/com/google/firebase/firestore/PipelineResult.kt new file mode 100644 index 00000000000..7df306e96e3 --- /dev/null +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/PipelineResult.kt @@ -0,0 +1,136 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.firestore + +import com.google.common.annotations.Beta +import com.google.firebase.Timestamp +import com.google.firebase.firestore.model.Document +import com.google.firebase.firestore.model.Values +import com.google.firestore.v1.Value + +/** + * Represents the results of a Pipeline query, including the data and metadata. It is usually + * accessed via [Pipeline.Snapshot]. + */ +@Beta +class PipelineResult +internal constructor( + private val userDataWriter: UserDataWriter, + ref: DocumentReference?, + private val fields: Map, + createTime: Timestamp?, + updateTime: Timestamp?, +) { + + internal constructor( + document: Document, + serverTimestampBehavior: DocumentSnapshot.ServerTimestampBehavior, + firestore: FirebaseFirestore + ) : this( + UserDataWriter(firestore, serverTimestampBehavior), + DocumentReference(document.key, firestore), + document.data.fieldsMap, + document.createTime?.timestamp, + document.version.timestamp + ) + + /** The time the document was created. Null if this result is not a document. */ + val createTime: Timestamp? = createTime + + /** + * The time the document was last updated (at the time the snapshot was generated). Null if this + * result is not a document. + */ + val updateTime: Timestamp? = updateTime + + /** + * The reference to the document, if the query returns the document id for a document. The name + * field will be returned by default if querying a document. + * + * Document ids will not be returned if certain pipeline stages omit the document id. For example, + * [Pipeline.select], [Pipeline.removeFields] and [Pipeline.aggregate] can omit the document id. + * + * @return [DocumentReference] Reference to the document, if applicable. + */ + val ref: DocumentReference? = ref + + /** + * Returns the ID of the document represented by this result. Returns null if this result is not + * corresponding to a Firestore document. + * + * @return ID of document, if applicable. + */ + fun getId(): String? = ref?.id + + /** + * Retrieves all fields in the result as an object map. + * + * @return Map of field names to objects. + */ + fun getData(): Map = userDataWriter.convertObject(fields) + + private fun extractNestedValue(fieldPath: FieldPath): Value? { + val segments = fieldPath.internalPath.iterator() + if (!segments.hasNext()) { + return Values.encodeValue(fields) + } + val firstSegment = segments.next() + if (!fields.containsKey(firstSegment)) { + return null + } + var value: Value? = fields[firstSegment] + for (segment in segments) { + if (value == null || !value.hasMapValue()) { + return null + } + value = value.mapValue.getFieldsOrDefault(segment, null) + } + return value + } + + /** + * Retrieves the field specified by [field]. + * + * @param field The field path (e.g. "foo" or "foo.bar") to a specific field. + * @return The data at the specified field location or null if no such field exists. + */ + fun get(field: String): Any? = get(FieldPath.fromDotSeparatedPath(field)) + + /** + * Retrieves the field specified by [fieldPath]. + * + * @param fieldPath The field path to a specific field. + * @return The data at the specified field location or null if no such field exists. + */ + fun get(fieldPath: FieldPath): Any? = userDataWriter.convertValue(extractNestedValue(fieldPath)) + + override fun toString() = "PipelineResult{ref=$ref, updateTime=$updateTime}, data=${getData()}" + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + other as PipelineResult + if (ref != other.ref) return false + if (fields != other.fields) return false + return true + } + + override fun hashCode(): Int { + var result = ref?.hashCode() ?: 0 + result = 31 * result + fields.hashCode() + return result + } +} diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/Selectable.kt b/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/Selectable.kt new file mode 100644 index 00000000000..6024c65f6d8 --- /dev/null +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/Selectable.kt @@ -0,0 +1,52 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.firestore.pipeline + +import com.google.common.annotations.Beta +import com.google.firebase.firestore.UserDataReader +import com.google.firebase.firestore.pipeline.evaluation.EvaluateDocument +import com.google.firebase.firestore.pipeline.evaluation.EvaluationContext +import com.google.firestore.v1.Value + +/** + * A selectable field or expression. + * + * This class abstracts over fields and expressions that can be selected in a pipeline. + */ +@Beta +abstract class Selectable internal constructor() : Expression() { + internal abstract val alias: String + internal abstract val expr: Expression + internal abstract override fun toProto(userDataReader: UserDataReader): Value + internal abstract override fun evaluateFunction(context: EvaluationContext): EvaluateDocument + internal abstract override fun canonicalId(): String + + override fun alias(alias: String): AliasedExpression { + return AliasedExpression(alias, this) + } + + internal companion object { + fun toSelectable(o: Any): Selectable { + return when (o) { + is Selectable -> o + is String -> Expression.field(o) + is com.google.firebase.firestore.FieldPath -> Expression.field(o.toString()) + else -> throw IllegalArgumentException("Unknown Selectable type: $o") + } + } + } +} diff --git a/firebase-firestore/src/test/java/com/google/firebase/firestore/pipeline/SubqueryPipelineTests.kt b/firebase-firestore/src/test/java/com/google/firebase/firestore/pipeline/SubqueryPipelineTests.kt new file mode 100644 index 00000000000..fe663bd34a5 --- /dev/null +++ b/firebase-firestore/src/test/java/com/google/firebase/firestore/pipeline/SubqueryPipelineTests.kt @@ -0,0 +1,109 @@ +package com.google.firebase.firestore.pipeline + +import com.google.common.truth.Truth.assertThat +import com.google.firebase.firestore.Pipeline +import com.google.firebase.firestore.TestUtil +import com.google.firebase.firestore.pipeline.Expression.Companion.currentDocument +import com.google.firebase.firestore.pipeline.Expression.Companion.field +import com.google.firebase.firestore.pipeline.Expression.Companion.variable +import com.google.firestore.v1.Value +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class SubqueryPipelineTests { + + private val db = TestUtil.firestore() + private val userDataReader = TestUtil.USER_DATA_READER + + @Test + fun `define creates DefineStage in proto`() { + // Manually construct Pipeline or use a helper + // Since Pipeline constructor is internal, we can access it from this internal class in the same module + val pipeline = Pipeline(db, userDataReader, emptyList()) + .define(field("title").alias("t")) + + val proto = pipeline.toPipelineProto(userDataReader) + assertThat(proto.stagesCount).isEqualTo(1) + val stage = proto.getStages(0) + assertThat(stage.name).isEqualTo("define") + // Verify args or options contains the variable + // DefineStage puts variables in args as map + assertThat(stage.argsCount).isEqualTo(1) + val mapValue = stage.getArgs(0).mapValue + assertThat(mapValue).isNotNull() + // Verify variable mapping + } + + @Test + fun `subcollection creates pipeline with SubcollectionSource`() { + val pipeline = Pipeline.subcollection("reviews") + // We must provide a reader override since "subcollection" uses null internally + val proto = pipeline.toPipelineProto(userDataReader) + + assertThat(proto.stagesCount).isEqualTo(1) + val stage = proto.getStages(0) + assertThat(stage.name).isEqualTo("subcollection") + val pathArg = stage.getArgs(0) + // args(0) is path string encoded? + // SubcollectionSource args: sequenceOf(encodeValue(path)) + assertThat(pathArg.stringValue).isEqualTo("reviews") + } + + @Test + fun `toArrayExpression creates FunctionExpression`() { + val subPipeline = Pipeline.subcollection("sub_items") + val expr = subPipeline.toArrayExpression() + + val protoValue = expr.toProto(userDataReader) + assertThat(protoValue.hasFunctionValue()).isTrue() + assertThat(protoValue.functionValue.name).isEqualTo("array") + assertThat(protoValue.functionValue.argsCount).isEqualTo(1) + val pipelineArg = protoValue.functionValue.getArgs(0) + assertThat(pipelineArg.hasPipelineValue()).isTrue() + } + + @Test + fun `toScalarExpression creates FunctionExpression`() { + val subPipeline = Pipeline.subcollection("sub_items") + val expr = subPipeline.toScalarExpression() + + val protoValue = expr.toProto(userDataReader) + assertThat(protoValue.hasFunctionValue()).isTrue() + assertThat(protoValue.functionValue.name).isEqualTo("scalar") + assertThat(protoValue.functionValue.argsCount).isEqualTo(1) + val pipelineArg = protoValue.functionValue.getArgs(0) + assertThat(pipelineArg.hasPipelineValue()).isTrue() + } + + @Test + fun `variable expression proto`() { + val v = variable("my_var") + val proto = v.toProto(userDataReader) + assertThat(proto.variableReferenceValue).isEqualTo("my_var") + } + + @Test + fun `currentDocument expression proto`() { + val cd = currentDocument() + val proto = cd.toProto(userDataReader) + assertThat(proto.hasFunctionValue()).isTrue() + assertThat(proto.functionValue.name).isEqualTo("current_document") + assertThat(proto.functionValue.argsCount).isEqualTo(0) + } + + @Test + fun `Expression getField creates field`() { + val v = variable("my_var") + val f2 = Expression.getField(v, "sub") + + val proto2 = f2.toProto(userDataReader) + + assertThat(proto2.hasFunctionValue()).isTrue() + assertThat(proto2.functionValue.name).isEqualTo("field") + assertThat(proto2.functionValue.argsCount).isEqualTo(2) + assertThat(proto2.functionValue.getArgs(0).variableReferenceValue).isEqualTo("my_var") + assertThat(proto2.functionValue.getArgs(1).stringValue).isEqualTo("sub") + } +} From d6d9e4cbdd6024ea3731468b2981745b5f07883e Mon Sep 17 00:00:00 2001 From: cherylEnkidu Date: Wed, 11 Feb 2026 17:34:25 -0500 Subject: [PATCH 03/53] fix naming and API style --- .../com/google/firebase/firestore/Pipeline.kt | 30 ++++++++++++++----- .../firebase/firestore/pipeline/stage.kt | 15 +++++----- 2 files changed, 31 insertions(+), 14 deletions(-) diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/Pipeline.kt b/firebase-firestore/src/main/java/com/google/firebase/firestore/Pipeline.kt index ea875ff14ed..6a7bb587359 100644 --- a/firebase-firestore/src/main/java/com/google/firebase/firestore/Pipeline.kt +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/Pipeline.kt @@ -18,10 +18,8 @@ import com.google.android.gms.tasks.Task import com.google.android.gms.tasks.TaskCompletionSource import com.google.common.annotations.Beta import com.google.firebase.Timestamp -import com.google.firebase.firestore.model.Document import com.google.firebase.firestore.model.DocumentKey import com.google.firebase.firestore.model.ResourcePath -import com.google.firebase.firestore.model.Values import com.google.firebase.firestore.pipeline.AbstractOptions import com.google.firebase.firestore.pipeline.AddFieldsStage import com.google.firebase.firestore.pipeline.AggregateFunction @@ -904,13 +902,31 @@ internal constructor( fun unnest(unnestStage: UnnestStage): Pipeline = append(unnestStage) /** - * Binds the given expressions to variables in the pipeline scope. + * Defines one or more variables in the pipeline's scope, allowing them to be used in subsequent stages. * - * @param variables One or more variables to bind. - * @return The [Pipeline] with the bound variables. + * This stage is useful for declaring reusable values or intermediate calculations that can be referenced + * multiple times in later parts of the pipeline, improving readability and maintainability. + * + * Each variable is defined using an [AliasedExpression], which pairs an expression with a name (alias). + * The expression can be a simple constant, a field reference, or a complex computation. + * + * Example: + * ``` + * firestore.pipeline().collection("products") + * .define( + * multiply(field("price"), 0.9).as("discountedPrice"), + * add(field("stock"), 10).as("newStock") + * ) + * .where(lessThan(variable("discountedPrice"), 100)) + * .select(field("name"), variable("newStock")); + * ``` + * + * @param aliasedExpression The first variable to define, specified as an [AliasedExpression]. + * @param additionalExpressions Optional additional variables to define, specified as [AliasedExpression]s. + * @return A new [Pipeline] object with this stage appended to the stage list. */ - fun define(vararg variables: AliasedExpression): Pipeline { - return append(DefineStage(variables)) + fun define(aliasedExpression: AliasedExpression, vararg additionalExpressions: AliasedExpression): Pipeline { + return append(DefineStage(arrayOf(aliasedExpression, *additionalExpressions))) } /** diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/stage.kt b/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/stage.kt index aa449795385..e09ed79f396 100644 --- a/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/stage.kt +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/stage.kt @@ -1321,31 +1321,32 @@ class UnnestOptions private constructor(options: InternalOptions) : } } -internal class DefineStage( - private val variables: Array, - options: InternalOptions = InternalOptions.EMPTY +internal class DefineStage +internal constructor( + private val aliasedExpressions: Array, + options: InternalOptions = InternalOptions.EMPTY ) : Stage("define", options) { - override fun self(options: InternalOptions) = DefineStage(variables, options) + override fun self(options: InternalOptions) = DefineStage(aliasedExpressions, options) override fun canonicalId(): String { TODO("Not yet implemented") } override fun args(userDataReader: UserDataReader): Sequence { - return sequenceOf(encodeValue(associateWithoutDuplications(variables, userDataReader))) + return sequenceOf(encodeValue(associateWithoutDuplications(aliasedExpressions, userDataReader))) } override fun equals(other: Any?): Boolean { if (this === other) return true if (other !is DefineStage) return false - if (!variables.contentEquals(other.variables)) return false + if (!aliasedExpressions.contentEquals(other.aliasedExpressions)) return false if (options != other.options) return false return true } override fun hashCode(): Int { - var result = variables.contentHashCode() + var result = aliasedExpressions.contentHashCode() result = 31 * result + options.hashCode() return result } From 808f1f2c2275c09af5ab9995be4f7530306d2f3e Mon Sep 17 00:00:00 2001 From: cherylEnkidu Date: Thu, 12 Feb 2026 11:16:18 -0500 Subject: [PATCH 04/53] revert unrelated changes --- .../com/google/firebase/firestore/Pipeline.kt | 115 +++++++++++++++ .../firebase/firestore/PipelineResult.kt | 136 ------------------ .../firebase/firestore/pipeline/Selectable.kt | 52 ------- .../firestore/pipeline/expressions.kt | 22 ++- .../proto/google/firestore/v1/document.proto | 2 +- 5 files changed, 136 insertions(+), 191 deletions(-) delete mode 100644 firebase-firestore/src/main/java/com/google/firebase/firestore/PipelineResult.kt delete mode 100644 firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/Selectable.kt diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/Pipeline.kt b/firebase-firestore/src/main/java/com/google/firebase/firestore/Pipeline.kt index 6a7bb587359..e61ac47a7f0 100644 --- a/firebase-firestore/src/main/java/com/google/firebase/firestore/Pipeline.kt +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/Pipeline.kt @@ -18,8 +18,10 @@ import com.google.android.gms.tasks.Task import com.google.android.gms.tasks.TaskCompletionSource import com.google.common.annotations.Beta import com.google.firebase.Timestamp +import com.google.firebase.firestore.model.Document import com.google.firebase.firestore.model.DocumentKey import com.google.firebase.firestore.model.ResourcePath +import com.google.firebase.firestore.model.Values import com.google.firebase.firestore.pipeline.AbstractOptions import com.google.firebase.firestore.pipeline.AddFieldsStage import com.google.firebase.firestore.pipeline.AggregateFunction @@ -1111,6 +1113,119 @@ class PipelineSource internal constructor(private val firestore: FirebaseFiresto } } +/** + * Represents the results of a Pipeline query, including the data and metadata. It is usually + * accessed via [Pipeline.Snapshot]. + */ +@Beta +class PipelineResult +internal constructor( + private val userDataWriter: UserDataWriter, + ref: DocumentReference?, + private val fields: Map, + createTime: Timestamp?, + updateTime: Timestamp?, +) { + + internal constructor( + document: Document, + serverTimestampBehavior: DocumentSnapshot.ServerTimestampBehavior, + firestore: FirebaseFirestore + ) : this( + UserDataWriter(firestore, serverTimestampBehavior), + DocumentReference(document.key, firestore), + document.data.fieldsMap, + document.createTime?.timestamp, + document.version.timestamp + ) + + /** The time the document was created. Null if this result is not a document. */ + val createTime: Timestamp? = createTime + + /** + * The time the document was last updated (at the time the snapshot was generated). Null if this + * result is not a document. + */ + val updateTime: Timestamp? = updateTime + + /** + * The reference to the document, if the query returns the document id for a document. The name + * field will be returned by default if querying a document. + * + * Document ids will not be returned if certain pipeline stages omit the document id. For example, + * [Pipeline.select], [Pipeline.removeFields] and [Pipeline.aggregate] can omit the document id. + * + * @return [DocumentReference] Reference to the document, if applicable. + */ + val ref: DocumentReference? = ref + + /** + * Returns the ID of the document represented by this result. Returns null if this result is not + * corresponding to a Firestore document. + * + * @return ID of document, if applicable. + */ + fun getId(): String? = ref?.id + + /** + * Retrieves all fields in the result as an object map. + * + * @return Map of field names to objects. + */ + fun getData(): Map = userDataWriter.convertObject(fields) + + private fun extractNestedValue(fieldPath: FieldPath): Value? { + val segments = fieldPath.internalPath.iterator() + if (!segments.hasNext()) { + return Values.encodeValue(fields) + } + val firstSegment = segments.next() + if (!fields.containsKey(firstSegment)) { + return null + } + var value: Value? = fields[firstSegment] + for (segment in segments) { + if (value == null || !value.hasMapValue()) { + return null + } + value = value.mapValue.getFieldsOrDefault(segment, null) + } + return value + } + + /** + * Retrieves the field specified by [field]. + * + * @param field The field path (e.g. "foo" or "foo.bar") to a specific field. + * @return The data at the specified field location or null if no such field exists. + */ + fun get(field: String): Any? = get(FieldPath.fromDotSeparatedPath(field)) + + /** + * Retrieves the field specified by [fieldPath]. + * + * @param fieldPath The field path to a specific field. + * @return The data at the specified field location or null if no such field exists. + */ + fun get(fieldPath: FieldPath): Any? = userDataWriter.convertValue(extractNestedValue(fieldPath)) + + override fun toString() = "PipelineResult{ref=$ref, updateTime=$updateTime}, data=${getData()}" + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + other as PipelineResult + if (ref != other.ref) return false + if (fields != other.fields) return false + return true + } + + override fun hashCode(): Int { + var result = ref?.hashCode() ?: 0 + result = 31 * result + fields.hashCode() + return result + } +} + internal interface PipelineResultObserver { fun onDocument( key: DocumentKey?, diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/PipelineResult.kt b/firebase-firestore/src/main/java/com/google/firebase/firestore/PipelineResult.kt deleted file mode 100644 index 7df306e96e3..00000000000 --- a/firebase-firestore/src/main/java/com/google/firebase/firestore/PipelineResult.kt +++ /dev/null @@ -1,136 +0,0 @@ -/* - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.google.firebase.firestore - -import com.google.common.annotations.Beta -import com.google.firebase.Timestamp -import com.google.firebase.firestore.model.Document -import com.google.firebase.firestore.model.Values -import com.google.firestore.v1.Value - -/** - * Represents the results of a Pipeline query, including the data and metadata. It is usually - * accessed via [Pipeline.Snapshot]. - */ -@Beta -class PipelineResult -internal constructor( - private val userDataWriter: UserDataWriter, - ref: DocumentReference?, - private val fields: Map, - createTime: Timestamp?, - updateTime: Timestamp?, -) { - - internal constructor( - document: Document, - serverTimestampBehavior: DocumentSnapshot.ServerTimestampBehavior, - firestore: FirebaseFirestore - ) : this( - UserDataWriter(firestore, serverTimestampBehavior), - DocumentReference(document.key, firestore), - document.data.fieldsMap, - document.createTime?.timestamp, - document.version.timestamp - ) - - /** The time the document was created. Null if this result is not a document. */ - val createTime: Timestamp? = createTime - - /** - * The time the document was last updated (at the time the snapshot was generated). Null if this - * result is not a document. - */ - val updateTime: Timestamp? = updateTime - - /** - * The reference to the document, if the query returns the document id for a document. The name - * field will be returned by default if querying a document. - * - * Document ids will not be returned if certain pipeline stages omit the document id. For example, - * [Pipeline.select], [Pipeline.removeFields] and [Pipeline.aggregate] can omit the document id. - * - * @return [DocumentReference] Reference to the document, if applicable. - */ - val ref: DocumentReference? = ref - - /** - * Returns the ID of the document represented by this result. Returns null if this result is not - * corresponding to a Firestore document. - * - * @return ID of document, if applicable. - */ - fun getId(): String? = ref?.id - - /** - * Retrieves all fields in the result as an object map. - * - * @return Map of field names to objects. - */ - fun getData(): Map = userDataWriter.convertObject(fields) - - private fun extractNestedValue(fieldPath: FieldPath): Value? { - val segments = fieldPath.internalPath.iterator() - if (!segments.hasNext()) { - return Values.encodeValue(fields) - } - val firstSegment = segments.next() - if (!fields.containsKey(firstSegment)) { - return null - } - var value: Value? = fields[firstSegment] - for (segment in segments) { - if (value == null || !value.hasMapValue()) { - return null - } - value = value.mapValue.getFieldsOrDefault(segment, null) - } - return value - } - - /** - * Retrieves the field specified by [field]. - * - * @param field The field path (e.g. "foo" or "foo.bar") to a specific field. - * @return The data at the specified field location or null if no such field exists. - */ - fun get(field: String): Any? = get(FieldPath.fromDotSeparatedPath(field)) - - /** - * Retrieves the field specified by [fieldPath]. - * - * @param fieldPath The field path to a specific field. - * @return The data at the specified field location or null if no such field exists. - */ - fun get(fieldPath: FieldPath): Any? = userDataWriter.convertValue(extractNestedValue(fieldPath)) - - override fun toString() = "PipelineResult{ref=$ref, updateTime=$updateTime}, data=${getData()}" - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - other as PipelineResult - if (ref != other.ref) return false - if (fields != other.fields) return false - return true - } - - override fun hashCode(): Int { - var result = ref?.hashCode() ?: 0 - result = 31 * result + fields.hashCode() - return result - } -} diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/Selectable.kt b/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/Selectable.kt deleted file mode 100644 index 6024c65f6d8..00000000000 --- a/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/Selectable.kt +++ /dev/null @@ -1,52 +0,0 @@ -/* - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.google.firebase.firestore.pipeline - -import com.google.common.annotations.Beta -import com.google.firebase.firestore.UserDataReader -import com.google.firebase.firestore.pipeline.evaluation.EvaluateDocument -import com.google.firebase.firestore.pipeline.evaluation.EvaluationContext -import com.google.firestore.v1.Value - -/** - * A selectable field or expression. - * - * This class abstracts over fields and expressions that can be selected in a pipeline. - */ -@Beta -abstract class Selectable internal constructor() : Expression() { - internal abstract val alias: String - internal abstract val expr: Expression - internal abstract override fun toProto(userDataReader: UserDataReader): Value - internal abstract override fun evaluateFunction(context: EvaluationContext): EvaluateDocument - internal abstract override fun canonicalId(): String - - override fun alias(alias: String): AliasedExpression { - return AliasedExpression(alias, this) - } - - internal companion object { - fun toSelectable(o: Any): Selectable { - return when (o) { - is Selectable -> o - is String -> Expression.field(o) - is com.google.firebase.firestore.FieldPath -> Expression.field(o.toString()) - else -> throw IllegalArgumentException("Unknown Selectable type: $o") - } - } - } -} diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/expressions.kt b/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/expressions.kt index 2d00fc3d5be..e23fecec6e1 100644 --- a/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/expressions.kt +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/expressions.kt @@ -5581,10 +5581,10 @@ abstract class Expression internal constructor() { * to calculated values. * * @param alias The alias to assign to this expression. - * @return A new [Selectable] (typically an [AliasedExpression]) that wraps this expression and + * @return A new [AliasedExpression] that wraps this expression and * associates it with the provided alias. */ - open fun alias(alias: String): Selectable = AliasedExpression(alias, this) + open fun alias(alias: String): AliasedExpression = AliasedExpression(alias, this) /** * Creates an expression that returns the document ID from this path expression. @@ -7430,6 +7430,24 @@ abstract class Expression internal constructor() { internal abstract fun evaluateFunction(context: EvaluationContext): EvaluateDocument } +/** Expressions that have an alias are [Selectable] */ +@Beta +abstract class Selectable : Expression() { + internal abstract val alias: String + internal abstract val expr: Expression + + internal companion object { + fun toSelectable(o: Any): Selectable { + return when (o) { + is Selectable -> o + is String -> field(o) + is FieldPath -> field(o) + else -> throw IllegalArgumentException("Unknown Selectable type: $o") + } + } + } +} + /** Represents an expression that will be given the alias in the output document. */ @Beta class AliasedExpression diff --git a/firebase-firestore/src/proto/google/firestore/v1/document.proto b/firebase-firestore/src/proto/google/firestore/v1/document.proto index 608351dfc60..c9b9a85adf4 100644 --- a/firebase-firestore/src/proto/google/firestore/v1/document.proto +++ b/firebase-firestore/src/proto/google/firestore/v1/document.proto @@ -268,4 +268,4 @@ message Pipeline { // Ordered list of stages to evaluate. repeated Stage stages = 1; -} \ No newline at end of file +} From c569ece8cbd47501ab702f9bbaff24877ed3a275 Mon Sep 17 00:00:00 2001 From: cherylEnkidu Date: Thu, 12 Feb 2026 11:23:19 -0500 Subject: [PATCH 05/53] format code --- .../com/google/firebase/firestore/Pipeline.kt | 255 +++++++++--------- .../firestore/pipeline/expressions.kt | 58 ++-- .../firebase/firestore/pipeline/stage.kt | 19 +- .../pipeline/SubqueryPipelineTests.kt | 15 +- 4 files changed, 181 insertions(+), 166 deletions(-) diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/Pipeline.kt b/firebase-firestore/src/main/java/com/google/firebase/firestore/Pipeline.kt index e61ac47a7f0..62de2085e19 100644 --- a/firebase-firestore/src/main/java/com/google/firebase/firestore/Pipeline.kt +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/Pipeline.kt @@ -35,6 +35,7 @@ import com.google.firebase.firestore.pipeline.CollectionGroupSource import com.google.firebase.firestore.pipeline.CollectionSource import com.google.firebase.firestore.pipeline.CollectionSourceOptions import com.google.firebase.firestore.pipeline.DatabaseSource +import com.google.firebase.firestore.pipeline.DefineStage import com.google.firebase.firestore.pipeline.DistinctStage import com.google.firebase.firestore.pipeline.DocumentsSource import com.google.firebase.firestore.pipeline.Expression @@ -55,15 +56,14 @@ import com.google.firebase.firestore.pipeline.SelectStage import com.google.firebase.firestore.pipeline.Selectable import com.google.firebase.firestore.pipeline.SortStage import com.google.firebase.firestore.pipeline.Stage +import com.google.firebase.firestore.pipeline.SubcollectionSource import com.google.firebase.firestore.pipeline.UnionStage import com.google.firebase.firestore.pipeline.UnnestOptions import com.google.firebase.firestore.pipeline.UnnestStage import com.google.firebase.firestore.pipeline.WhereStage +import com.google.firebase.firestore.pipeline.evaluation.notImplemented import com.google.firebase.firestore.remote.RemoteSerializer import com.google.firebase.firestore.util.Logger -import com.google.firebase.firestore.pipeline.DefineStage -import com.google.firebase.firestore.pipeline.evaluation.notImplemented -import com.google.firebase.firestore.pipeline.SubcollectionSource import com.google.firestore.v1.ExecutePipelineRequest import com.google.firestore.v1.Pipeline as ProtoPipeline import com.google.firestore.v1.StructuredPipeline @@ -145,7 +145,10 @@ internal constructor( return Pipeline(firestore, userDataReader, stages.plus(stage)) } - private fun toStructuredPipelineProto(options: InternalOptions?, userDataReader: UserDataReader): StructuredPipeline { + private fun toStructuredPipelineProto( + options: InternalOptions?, + userDataReader: UserDataReader + ): StructuredPipeline { val builder = StructuredPipeline.newBuilder() builder.pipeline = toPipelineProto(userDataReader) options?.forEach(builder::putOptions) @@ -153,17 +156,19 @@ internal constructor( } internal fun toPipelineProto(userDataReader: UserDataReader): ProtoPipeline { - return ProtoPipeline.newBuilder().addAllStages(stages.map { it.toProtoStage(userDataReader) }).build() + return ProtoPipeline.newBuilder() + .addAllStages(stages.map { it.toProtoStage(userDataReader) }) + .build() } - private fun toExecutePipelineRequest(options: InternalOptions?): ExecutePipelineRequest { - checkNotNull(firestore) { "Cannot execute pipeline without a Firestore instance" } - val database = firestore!!.databaseId - val builder = ExecutePipelineRequest.newBuilder() - builder.database = "projects/${database.projectId}/databases/${database.databaseId}" - builder.structuredPipeline = toStructuredPipelineProto(options, firestore.userDataReader) - return builder.build() - } + private fun toExecutePipelineRequest(options: InternalOptions?): ExecutePipelineRequest { + checkNotNull(firestore) { "Cannot execute pipeline without a Firestore instance" } + val database = firestore!!.databaseId + val builder = ExecutePipelineRequest.newBuilder() + builder.database = "projects/${database.projectId}/databases/${database.databaseId}" + builder.structuredPipeline = toStructuredPipelineProto(options, firestore.userDataReader) + return builder.build() + } /** * Executes this pipeline and returns the results as a [Task] of [Snapshot]. @@ -904,13 +909,15 @@ internal constructor( fun unnest(unnestStage: UnnestStage): Pipeline = append(unnestStage) /** - * Defines one or more variables in the pipeline's scope, allowing them to be used in subsequent stages. + * Defines one or more variables in the pipeline's scope, allowing them to be used in subsequent + * stages. * - * This stage is useful for declaring reusable values or intermediate calculations that can be referenced - * multiple times in later parts of the pipeline, improving readability and maintainability. + * This stage is useful for declaring reusable values or intermediate calculations that can be + * referenced multiple times in later parts of the pipeline, improving readability and + * maintainability. * - * Each variable is defined using an [AliasedExpression], which pairs an expression with a name (alias). - * The expression can be a simple constant, a field reference, or a complex computation. + * Each variable is defined using an [AliasedExpression], which pairs an expression with a name + * (alias). The expression can be a simple constant, a field reference, or a complex computation. * * Example: * ``` @@ -924,10 +931,14 @@ internal constructor( * ``` * * @param aliasedExpression The first variable to define, specified as an [AliasedExpression]. - * @param additionalExpressions Optional additional variables to define, specified as [AliasedExpression]s. + * @param additionalExpressions Optional additional variables to define, specified as + * [AliasedExpression]s. * @return A new [Pipeline] object with this stage appended to the stage list. */ - fun define(aliasedExpression: AliasedExpression, vararg additionalExpressions: AliasedExpression): Pipeline { + fun define( + aliasedExpression: AliasedExpression, + vararg additionalExpressions: AliasedExpression + ): Pipeline { return append(DefineStage(arrayOf(aliasedExpression, *additionalExpressions))) } @@ -941,8 +952,8 @@ internal constructor( } /** - * Converts this pipeline to an expression that evaluates to a scalar result. - * The pipeline must return exactly one document with one field, or be an aggregation. + * Converts this pipeline to an expression that evaluates to a scalar result. The pipeline must + * return exactly one document with one field, or be an aggregation. * * @return An [Expression] that executes this pipeline and returns a single value. */ @@ -952,15 +963,15 @@ internal constructor( companion object { /** - * Creates a pipeline that processes the documents in the specified subcollection of the current - * document. - * - * @param path The relative path to the subcollection. - * @return A new [Pipeline] scoped to the subcollection. - */ + * Creates a pipeline that processes the documents in the specified subcollection of the current + * document. + * + * @param path The relative path to the subcollection. + * @return A new [Pipeline] scoped to the subcollection. + */ @JvmStatic fun subcollection(path: String): Pipeline { - return Pipeline(null, null, SubcollectionSource(path)) + return Pipeline(null, null, SubcollectionSource(path)) } } } @@ -1120,110 +1131,110 @@ class PipelineSource internal constructor(private val firestore: FirebaseFiresto @Beta class PipelineResult internal constructor( - private val userDataWriter: UserDataWriter, - ref: DocumentReference?, - private val fields: Map, - createTime: Timestamp?, - updateTime: Timestamp?, + private val userDataWriter: UserDataWriter, + ref: DocumentReference?, + private val fields: Map, + createTime: Timestamp?, + updateTime: Timestamp?, ) { - internal constructor( - document: Document, - serverTimestampBehavior: DocumentSnapshot.ServerTimestampBehavior, - firestore: FirebaseFirestore - ) : this( - UserDataWriter(firestore, serverTimestampBehavior), - DocumentReference(document.key, firestore), - document.data.fieldsMap, - document.createTime?.timestamp, - document.version.timestamp - ) + internal constructor( + document: Document, + serverTimestampBehavior: DocumentSnapshot.ServerTimestampBehavior, + firestore: FirebaseFirestore + ) : this( + UserDataWriter(firestore, serverTimestampBehavior), + DocumentReference(document.key, firestore), + document.data.fieldsMap, + document.createTime?.timestamp, + document.version.timestamp + ) - /** The time the document was created. Null if this result is not a document. */ - val createTime: Timestamp? = createTime + /** The time the document was created. Null if this result is not a document. */ + val createTime: Timestamp? = createTime - /** - * The time the document was last updated (at the time the snapshot was generated). Null if this - * result is not a document. - */ - val updateTime: Timestamp? = updateTime + /** + * The time the document was last updated (at the time the snapshot was generated). Null if this + * result is not a document. + */ + val updateTime: Timestamp? = updateTime - /** - * The reference to the document, if the query returns the document id for a document. The name - * field will be returned by default if querying a document. - * - * Document ids will not be returned if certain pipeline stages omit the document id. For example, - * [Pipeline.select], [Pipeline.removeFields] and [Pipeline.aggregate] can omit the document id. - * - * @return [DocumentReference] Reference to the document, if applicable. - */ - val ref: DocumentReference? = ref + /** + * The reference to the document, if the query returns the document id for a document. The name + * field will be returned by default if querying a document. + * + * Document ids will not be returned if certain pipeline stages omit the document id. For example, + * [Pipeline.select], [Pipeline.removeFields] and [Pipeline.aggregate] can omit the document id. + * + * @return [DocumentReference] Reference to the document, if applicable. + */ + val ref: DocumentReference? = ref - /** - * Returns the ID of the document represented by this result. Returns null if this result is not - * corresponding to a Firestore document. - * - * @return ID of document, if applicable. - */ - fun getId(): String? = ref?.id + /** + * Returns the ID of the document represented by this result. Returns null if this result is not + * corresponding to a Firestore document. + * + * @return ID of document, if applicable. + */ + fun getId(): String? = ref?.id - /** - * Retrieves all fields in the result as an object map. - * - * @return Map of field names to objects. - */ - fun getData(): Map = userDataWriter.convertObject(fields) - - private fun extractNestedValue(fieldPath: FieldPath): Value? { - val segments = fieldPath.internalPath.iterator() - if (!segments.hasNext()) { - return Values.encodeValue(fields) - } - val firstSegment = segments.next() - if (!fields.containsKey(firstSegment)) { - return null - } - var value: Value? = fields[firstSegment] - for (segment in segments) { - if (value == null || !value.hasMapValue()) { - return null - } - value = value.mapValue.getFieldsOrDefault(segment, null) - } - return value - } + /** + * Retrieves all fields in the result as an object map. + * + * @return Map of field names to objects. + */ + fun getData(): Map = userDataWriter.convertObject(fields) - /** - * Retrieves the field specified by [field]. - * - * @param field The field path (e.g. "foo" or "foo.bar") to a specific field. - * @return The data at the specified field location or null if no such field exists. - */ - fun get(field: String): Any? = get(FieldPath.fromDotSeparatedPath(field)) + private fun extractNestedValue(fieldPath: FieldPath): Value? { + val segments = fieldPath.internalPath.iterator() + if (!segments.hasNext()) { + return Values.encodeValue(fields) + } + val firstSegment = segments.next() + if (!fields.containsKey(firstSegment)) { + return null + } + var value: Value? = fields[firstSegment] + for (segment in segments) { + if (value == null || !value.hasMapValue()) { + return null + } + value = value.mapValue.getFieldsOrDefault(segment, null) + } + return value + } - /** - * Retrieves the field specified by [fieldPath]. - * - * @param fieldPath The field path to a specific field. - * @return The data at the specified field location or null if no such field exists. - */ - fun get(fieldPath: FieldPath): Any? = userDataWriter.convertValue(extractNestedValue(fieldPath)) + /** + * Retrieves the field specified by [field]. + * + * @param field The field path (e.g. "foo" or "foo.bar") to a specific field. + * @return The data at the specified field location or null if no such field exists. + */ + fun get(field: String): Any? = get(FieldPath.fromDotSeparatedPath(field)) - override fun toString() = "PipelineResult{ref=$ref, updateTime=$updateTime}, data=${getData()}" - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - other as PipelineResult - if (ref != other.ref) return false - if (fields != other.fields) return false - return true - } + /** + * Retrieves the field specified by [fieldPath]. + * + * @param fieldPath The field path to a specific field. + * @return The data at the specified field location or null if no such field exists. + */ + fun get(fieldPath: FieldPath): Any? = userDataWriter.convertValue(extractNestedValue(fieldPath)) + + override fun toString() = "PipelineResult{ref=$ref, updateTime=$updateTime}, data=${getData()}" + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + other as PipelineResult + if (ref != other.ref) return false + if (fields != other.fields) return false + return true + } - override fun hashCode(): Int { - var result = ref?.hashCode() ?: 0 - result = 31 * result + fields.hashCode() - return result - } + override fun hashCode(): Int { + var result = ref?.hashCode() ?: 0 + result = 31 * result + fields.hashCode() + return result + } } internal interface PipelineResultObserver { diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/expressions.kt b/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/expressions.kt index e23fecec6e1..bc2736c59e0 100644 --- a/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/expressions.kt +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/expressions.kt @@ -3091,15 +3091,16 @@ abstract class Expression internal constructor() { fun map(elements: Map): Expression = map(elements.flatMap { listOf(constant(it.key), toExprOrConstant(it.value)) }.toTypedArray()) - /** - * Accesses a field/property of the expression (useful when the expression evaluates to a Map or Document). - * - * @param key The key of the field to access. - * @return An [Expression] representing the value of the field. - */ - @JvmStatic - fun getField(expression: Expression, key: String): Expression = - FunctionExpression("field", notImplemented, expression, key) + /** + * Accesses a field/property of the expression (useful when the expression evaluates to a Map or + * Document). + * + * @param key The key of the field to access. + * @return An [Expression] representing the value of the field. + */ + @JvmStatic + fun getField(expression: Expression, key: String): Expression = + FunctionExpression("field", notImplemented, expression, key) /** * Accesses a value from a map (object) field using the provided [key]. @@ -5428,8 +5429,8 @@ abstract class Expression internal constructor() { * * @return An [Expression] representing the current document. */ - @JvmStatic fun currentDocument(): Expression = - FunctionExpression("current_document", notImplemented) + @JvmStatic + fun currentDocument(): Expression = FunctionExpression("current_document", notImplemented) } /** @@ -5581,8 +5582,8 @@ abstract class Expression internal constructor() { * to calculated values. * * @param alias The alias to assign to this expression. - * @return A new [AliasedExpression] that wraps this expression and - * associates it with the provided alias. + * @return A new [AliasedExpression] that wraps this expression and associates it with the + * provided alias. */ open fun alias(alias: String): AliasedExpression = AliasedExpression(alias, this) @@ -7433,19 +7434,19 @@ abstract class Expression internal constructor() { /** Expressions that have an alias are [Selectable] */ @Beta abstract class Selectable : Expression() { - internal abstract val alias: String - internal abstract val expr: Expression - - internal companion object { - fun toSelectable(o: Any): Selectable { - return when (o) { - is Selectable -> o - is String -> field(o) - is FieldPath -> field(o) - else -> throw IllegalArgumentException("Unknown Selectable type: $o") - } - } + internal abstract val alias: String + internal abstract val expr: Expression + + internal companion object { + fun toSelectable(o: Any): Selectable { + return when (o) { + is Selectable -> o + is String -> field(o) + is FieldPath -> field(o) + else -> throw IllegalArgumentException("Unknown Selectable type: $o") + } } + } } /** Represents an expression that will be given the alias in the output document. */ @@ -7763,7 +7764,11 @@ internal class BooleanFunctionExpression internal constructor(val expr: Expressi function: EvaluateFunction, fieldName: String, vararg params: Any - ) : this(name, function, arrayOf(Expression.field(fieldName), *Expression.toArrayOfExprOrConstant(params))) + ) : this( + name, + function, + arrayOf(Expression.field(fieldName), *Expression.toArrayOfExprOrConstant(params)) + ) override fun toProto(userDataReader: UserDataReader): Value = expr.toProto(userDataReader) @@ -7932,4 +7937,3 @@ private class PipelineValueExpression(val pipeline: Pipeline) : Expression() { override fun canonicalId() = "pipeline(\${pipeline.hashCode()})" override fun toString() = "Pipeline(...)" } - diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/stage.kt b/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/stage.kt index e09ed79f396..9ea27aa747e 100644 --- a/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/stage.kt +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/stage.kt @@ -399,15 +399,16 @@ internal constructor( } class SubcollectionSource( - internal val path: String, - options: InternalOptions = InternalOptions.EMPTY + internal val path: String, + options: InternalOptions = InternalOptions.EMPTY ) : Stage("subcollection", options) { - override fun self(options: InternalOptions) = SubcollectionSource(path, options) + override fun self(options: InternalOptions) = SubcollectionSource(path, options) - override fun canonicalId(): String = "${name}($path)" + override fun canonicalId(): String = "${name}($path)" - override fun args(userDataReader: UserDataReader): Sequence = sequenceOf(Values.encodeValue(path)) + override fun args(userDataReader: UserDataReader): Sequence = + sequenceOf(Values.encodeValue(path)) } private fun associateWithoutDuplications( @@ -1323,18 +1324,18 @@ class UnnestOptions private constructor(options: InternalOptions) : internal class DefineStage internal constructor( - private val aliasedExpressions: Array, - options: InternalOptions = InternalOptions.EMPTY + private val aliasedExpressions: Array, + options: InternalOptions = InternalOptions.EMPTY ) : Stage("define", options) { override fun self(options: InternalOptions) = DefineStage(aliasedExpressions, options) override fun canonicalId(): String { - TODO("Not yet implemented") + TODO("Not yet implemented") } override fun args(userDataReader: UserDataReader): Sequence { - return sequenceOf(encodeValue(associateWithoutDuplications(aliasedExpressions, userDataReader))) + return sequenceOf(encodeValue(associateWithoutDuplications(aliasedExpressions, userDataReader))) } override fun equals(other: Any?): Boolean { diff --git a/firebase-firestore/src/test/java/com/google/firebase/firestore/pipeline/SubqueryPipelineTests.kt b/firebase-firestore/src/test/java/com/google/firebase/firestore/pipeline/SubqueryPipelineTests.kt index fe663bd34a5..f1d372901d2 100644 --- a/firebase-firestore/src/test/java/com/google/firebase/firestore/pipeline/SubqueryPipelineTests.kt +++ b/firebase-firestore/src/test/java/com/google/firebase/firestore/pipeline/SubqueryPipelineTests.kt @@ -6,7 +6,6 @@ import com.google.firebase.firestore.TestUtil import com.google.firebase.firestore.pipeline.Expression.Companion.currentDocument import com.google.firebase.firestore.pipeline.Expression.Companion.field import com.google.firebase.firestore.pipeline.Expression.Companion.variable -import com.google.firestore.v1.Value import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner @@ -20,9 +19,9 @@ internal class SubqueryPipelineTests { @Test fun `define creates DefineStage in proto`() { // Manually construct Pipeline or use a helper - // Since Pipeline constructor is internal, we can access it from this internal class in the same module - val pipeline = Pipeline(db, userDataReader, emptyList()) - .define(field("title").alias("t")) + // Since Pipeline constructor is internal, we can access it from this internal class in the same + // module + val pipeline = Pipeline(db, userDataReader, emptyList()).define(field("title").alias("t")) val proto = pipeline.toPipelineProto(userDataReader) assertThat(proto.stagesCount).isEqualTo(1) @@ -41,7 +40,7 @@ internal class SubqueryPipelineTests { val pipeline = Pipeline.subcollection("reviews") // We must provide a reader override since "subcollection" uses null internally val proto = pipeline.toPipelineProto(userDataReader) - + assertThat(proto.stagesCount).isEqualTo(1) val stage = proto.getStages(0) assertThat(stage.name).isEqualTo("subcollection") @@ -55,7 +54,7 @@ internal class SubqueryPipelineTests { fun `toArrayExpression creates FunctionExpression`() { val subPipeline = Pipeline.subcollection("sub_items") val expr = subPipeline.toArrayExpression() - + val protoValue = expr.toProto(userDataReader) assertThat(protoValue.hasFunctionValue()).isTrue() assertThat(protoValue.functionValue.name).isEqualTo("array") @@ -63,12 +62,12 @@ internal class SubqueryPipelineTests { val pipelineArg = protoValue.functionValue.getArgs(0) assertThat(pipelineArg.hasPipelineValue()).isTrue() } - + @Test fun `toScalarExpression creates FunctionExpression`() { val subPipeline = Pipeline.subcollection("sub_items") val expr = subPipeline.toScalarExpression() - + val protoValue = expr.toProto(userDataReader) assertThat(protoValue.hasFunctionValue()).isTrue() assertThat(protoValue.functionValue.name).isEqualTo("scalar") From 5e8782a1f3284963d6ca8c664832d793016ea439 Mon Sep 17 00:00:00 2001 From: cherylEnkidu Date: Thu, 12 Feb 2026 11:32:45 -0500 Subject: [PATCH 06/53] fix docStubs --- .../com/google/firebase/firestore/pipeline/expressions.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/expressions.kt b/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/expressions.kt index bc2736c59e0..9f6def10adf 100644 --- a/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/expressions.kt +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/expressions.kt @@ -7916,7 +7916,7 @@ class Ordering internal constructor(val expr: Expression, val dir: Direction) { internal class Variable(val name: String) : Expression() { override fun toProto(userDataReader: UserDataReader): Value = Value.newBuilder().setVariableReferenceValue(name).build() - override fun evaluateFunction(context: EvaluationContext) = { _: MutableDocument -> + override fun evaluateFunction(context: EvaluationContext): EvaluateDocument = { _: MutableDocument -> throw NotImplementedError("Variable evaluation not implemented") } override fun canonicalId() = "var($name)" @@ -7931,7 +7931,7 @@ internal class Variable(val name: String) : Expression() { private class PipelineValueExpression(val pipeline: Pipeline) : Expression() { override fun toProto(userDataReader: UserDataReader): Value = Value.newBuilder().setPipelineValue(pipeline.toPipelineProto(userDataReader)).build() - override fun evaluateFunction(context: EvaluationContext) = { _: MutableDocument -> + override fun evaluateFunction(context: EvaluationContext): EvaluateDocument = { _: MutableDocument -> throw NotImplementedError("Pipeline evaluation not implemented") } override fun canonicalId() = "pipeline(\${pipeline.hashCode()})" From 07bb14fff18bc39e9d61393fdd212b369d3bbf04 Mon Sep 17 00:00:00 2001 From: cherylEnkidu Date: Thu, 12 Feb 2026 13:24:42 -0500 Subject: [PATCH 07/53] Add test sample --- .../firestore/SubqueryIntegrationTest.kt | 97 +++++++++++++++++++ .../firebase/firestore/pipeline/stage.kt | 2 +- 2 files changed, 98 insertions(+), 1 deletion(-) create mode 100644 firebase-firestore/src/androidTest/java/com/google/firebase/firestore/SubqueryIntegrationTest.kt diff --git a/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/SubqueryIntegrationTest.kt b/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/SubqueryIntegrationTest.kt new file mode 100644 index 00000000000..7b3a66a4310 --- /dev/null +++ b/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/SubqueryIntegrationTest.kt @@ -0,0 +1,97 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.firestore + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import com.google.firebase.firestore.pipeline.Expression.Companion.equal +import com.google.firebase.firestore.pipeline.Expression.Companion.field +import com.google.firebase.firestore.pipeline.Expression.Companion.variable +import com.google.firebase.firestore.testutil.IntegrationTestUtil +import com.google.firebase.firestore.testutil.IntegrationTestUtil.waitFor +import com.google.firebase.firestore.util.Util.autoId +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class SubqueryIntegrationTest { + private lateinit var db: FirebaseFirestore + + @Before + fun setUp() { + // org.junit.Assume.assumeTrue( + // "Skip SubqueryIntegrationTest on prod", + // IntegrationTestUtil.isRunningAgainstEmulator() + // ) + org.junit.Assume.assumeTrue( + "Skip SubqueryIntegrationTest on standard backend", + IntegrationTestUtil.getBackendEdition() == IntegrationTestUtil.BackendEdition.ENTERPRISE + ) + + // Using testFirestore() ensures we get a uniquely configured instance if needed, + // but typically we want a clean DB reference. + // IntegrationTestUtil.testFirestore() is standard. + val collRef = IntegrationTestUtil.testCollection() + db = collRef.firestore + } + + @After + fun tearDown() { + IntegrationTestUtil.tearDown() + } + + @Test + fun testSubquery() { + val reviewCollectionId = autoId() + val reviewerCollectionId = autoId() + + // Setup reviewers + val reviewersCollection = db.collection(reviewerCollectionId) + val r1 = reviewersCollection.document("r1") + waitFor(r1.set(mapOf("name" to "reviewer1"))) + + // Setup reviews + // Using collectionGroup requires consistent collection ID across hierarchy or just any collection with that ID. + // We'll create a top-level collection with the random ID for simplicity. + val reviewsRef = db.collection(reviewCollectionId) + + // Store author as a DocumentReference to match __name__ which is a Reference. + waitFor(reviewsRef.document("run1_1").set(mapOf("author" to r1, "rating" to 5))) + waitFor(reviewsRef.document("run1_2").set(mapOf("author" to r1, "rating" to 3))) + + // Construct subquery + // Find reviews where author matches the variable 'author' + val subquery = db.pipeline().collectionGroup(reviewCollectionId) + .where(equal("author", variable("author"))) + .aggregate(field("rating").average().alias("avg_rating")) + + // Construct main pipeline + val pipeline = db.pipeline().collection(reviewerCollectionId) + .define(field("__name__").alias("author")) + .addFields(subquery.toScalarExpression().alias("avg_review")) + + // Execute + val results = waitFor(pipeline.execute()) + + // Check results + assertThat(results).hasSize(1) + val doc = results.first() + assertThat(doc.get("avg_review")).isEqualTo(4.0) + } +} diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/stage.kt b/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/stage.kt index 9ea27aa747e..444316fc9be 100644 --- a/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/stage.kt +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/stage.kt @@ -1326,7 +1326,7 @@ internal class DefineStage internal constructor( private val aliasedExpressions: Array, options: InternalOptions = InternalOptions.EMPTY -) : Stage("define", options) { +) : Stage("let", options) { override fun self(options: InternalOptions) = DefineStage(aliasedExpressions, options) From 5b61e2681fc9197c97364b052f7b710da91f955c Mon Sep 17 00:00:00 2001 From: cherylEnkidu Date: Thu, 12 Feb 2026 13:30:12 -0500 Subject: [PATCH 08/53] fix firebase-firestore:compileDebugKotlin --- .../firestore/SubqueryIntegrationTest.kt | 19 +++++++++++++------ .../firestore/pipeline/expressions.kt | 14 ++++++++------ .../firebase/firestore/pipeline/stage.kt | 10 +++++----- 3 files changed, 26 insertions(+), 17 deletions(-) diff --git a/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/SubqueryIntegrationTest.kt b/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/SubqueryIntegrationTest.kt index 7b3a66a4310..4a526d4c48f 100644 --- a/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/SubqueryIntegrationTest.kt +++ b/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/SubqueryIntegrationTest.kt @@ -44,7 +44,7 @@ class SubqueryIntegrationTest { IntegrationTestUtil.getBackendEdition() == IntegrationTestUtil.BackendEdition.ENTERPRISE ) - // Using testFirestore() ensures we get a uniquely configured instance if needed, + // Using testFirestore() ensures we get a uniquely configured instance if needed, // but typically we want a clean DB reference. // IntegrationTestUtil.testFirestore() is standard. val collRef = IntegrationTestUtil.testCollection() @@ -67,28 +67,35 @@ class SubqueryIntegrationTest { waitFor(r1.set(mapOf("name" to "reviewer1"))) // Setup reviews - // Using collectionGroup requires consistent collection ID across hierarchy or just any collection with that ID. + // Using collectionGroup requires consistent collection ID across hierarchy or just any + // collection with that ID. // We'll create a top-level collection with the random ID for simplicity. val reviewsRef = db.collection(reviewCollectionId) - + // Store author as a DocumentReference to match __name__ which is a Reference. waitFor(reviewsRef.document("run1_1").set(mapOf("author" to r1, "rating" to 5))) waitFor(reviewsRef.document("run1_2").set(mapOf("author" to r1, "rating" to 3))) // Construct subquery // Find reviews where author matches the variable 'author' - val subquery = db.pipeline().collectionGroup(reviewCollectionId) + val subquery = + db + .pipeline() + .collectionGroup(reviewCollectionId) .where(equal("author", variable("author"))) .aggregate(field("rating").average().alias("avg_rating")) // Construct main pipeline - val pipeline = db.pipeline().collection(reviewerCollectionId) + val pipeline = + db + .pipeline() + .collection(reviewerCollectionId) .define(field("__name__").alias("author")) .addFields(subquery.toScalarExpression().alias("avg_review")) // Execute val results = waitFor(pipeline.execute()) - + // Check results assertThat(results).hasSize(1) val doc = results.first() diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/expressions.kt b/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/expressions.kt index 9f6def10adf..14ae6f54963 100644 --- a/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/expressions.kt +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/expressions.kt @@ -7916,9 +7916,10 @@ class Ordering internal constructor(val expr: Expression, val dir: Direction) { internal class Variable(val name: String) : Expression() { override fun toProto(userDataReader: UserDataReader): Value = Value.newBuilder().setVariableReferenceValue(name).build() - override fun evaluateFunction(context: EvaluationContext): EvaluateDocument = { _: MutableDocument -> - throw NotImplementedError("Variable evaluation not implemented") - } + override fun evaluateFunction(context: EvaluationContext): EvaluateDocument = + { _: MutableDocument -> + throw NotImplementedError("Variable evaluation not implemented") + } override fun canonicalId() = "var($name)" override fun equals(other: Any?): Boolean { if (this === other) return true @@ -7931,9 +7932,10 @@ internal class Variable(val name: String) : Expression() { private class PipelineValueExpression(val pipeline: Pipeline) : Expression() { override fun toProto(userDataReader: UserDataReader): Value = Value.newBuilder().setPipelineValue(pipeline.toPipelineProto(userDataReader)).build() - override fun evaluateFunction(context: EvaluationContext): EvaluateDocument = { _: MutableDocument -> - throw NotImplementedError("Pipeline evaluation not implemented") - } + override fun evaluateFunction(context: EvaluationContext): EvaluateDocument = + { _: MutableDocument -> + throw NotImplementedError("Pipeline evaluation not implemented") + } override fun canonicalId() = "pipeline(\${pipeline.hashCode()})" override fun toString() = "Pipeline(...)" } diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/stage.kt b/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/stage.kt index 444316fc9be..05c4bc4de0e 100644 --- a/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/stage.kt +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/stage.kt @@ -297,7 +297,8 @@ class CollectionHints internal constructor(options: InternalOptions) : } } -class CollectionGroupSource(val collectionId: String, options: InternalOptions) : +class CollectionGroupSource +internal constructor(val collectionId: String, options: InternalOptions) : Stage("collection_group", options) { internal constructor( @@ -398,10 +399,9 @@ internal constructor( documents.asSequence().map(::encodeValue) } -class SubcollectionSource( - internal val path: String, - options: InternalOptions = InternalOptions.EMPTY -) : Stage("subcollection", options) { +class SubcollectionSource +internal constructor(internal val path: String, options: InternalOptions = InternalOptions.EMPTY) : + Stage("subcollection", options) { override fun self(options: InternalOptions) = SubcollectionSource(path, options) From d9e8bd414f8abde433785085e0e5267915836570 Mon Sep 17 00:00:00 2001 From: cherylEnkidu Date: Thu, 12 Feb 2026 14:03:26 -0500 Subject: [PATCH 09/53] change define to let --- .../google/firebase/firestore/pipeline/SubqueryPipelineTests.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/firebase-firestore/src/test/java/com/google/firebase/firestore/pipeline/SubqueryPipelineTests.kt b/firebase-firestore/src/test/java/com/google/firebase/firestore/pipeline/SubqueryPipelineTests.kt index f1d372901d2..e0b341bf2c8 100644 --- a/firebase-firestore/src/test/java/com/google/firebase/firestore/pipeline/SubqueryPipelineTests.kt +++ b/firebase-firestore/src/test/java/com/google/firebase/firestore/pipeline/SubqueryPipelineTests.kt @@ -26,7 +26,7 @@ internal class SubqueryPipelineTests { val proto = pipeline.toPipelineProto(userDataReader) assertThat(proto.stagesCount).isEqualTo(1) val stage = proto.getStages(0) - assertThat(stage.name).isEqualTo("define") + assertThat(stage.name).isEqualTo("let") // Verify args or options contains the variable // DefineStage puts variables in args as map assertThat(stage.argsCount).isEqualTo(1) From 51bcf4af100f1577a3c528165769c52a1461fd32 Mon Sep 17 00:00:00 2001 From: cherylEnkidu Date: Thu, 12 Feb 2026 14:48:37 -0500 Subject: [PATCH 10/53] skip the subquery test for now --- .../com/google/firebase/firestore/SubqueryIntegrationTest.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/SubqueryIntegrationTest.kt b/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/SubqueryIntegrationTest.kt index 4a526d4c48f..f4bc03180a7 100644 --- a/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/SubqueryIntegrationTest.kt +++ b/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/SubqueryIntegrationTest.kt @@ -56,6 +56,7 @@ class SubqueryIntegrationTest { IntegrationTestUtil.tearDown() } + @org.junit.Ignore @Test fun testSubquery() { val reviewCollectionId = autoId() From 614183167f9faaac20266c49fddf2ec4a075a976 Mon Sep 17 00:00:00 2001 From: cherylEnkidu Date: Thu, 12 Feb 2026 15:26:46 -0500 Subject: [PATCH 11/53] Fixed a race condition in --- .../java/com/google/firebase/firestore/QueryToPipelineTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/QueryToPipelineTest.java b/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/QueryToPipelineTest.java index 1e30eb98a77..c632844c47b 100644 --- a/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/QueryToPipelineTest.java +++ b/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/QueryToPipelineTest.java @@ -352,7 +352,7 @@ public void testCanQueryByDocumentIdUsingRefs() { public void testCanQueryWithAndWithoutDocumentKey() { CollectionReference collection = testCollection(); FirebaseFirestore db = collection.firestore; - collection.add(map()); + waitFor(collection.add(map())); Task query1 = db.pipeline() .createFrom(collection.orderBy(FieldPath.documentId(), Direction.ASCENDING)) From 2a61d92c34c1e29884bf96619f3c6d034dec44c4 Mon Sep 17 00:00:00 2001 From: cherylEnkidu Date: Thu, 12 Feb 2026 16:43:28 -0500 Subject: [PATCH 12/53] remove is_not_null --- .../com/google/firebase/firestore/RealtimePipelineTest.kt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/RealtimePipelineTest.kt b/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/RealtimePipelineTest.kt index 1d8d16efaee..dbc2690d3e2 100644 --- a/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/RealtimePipelineTest.kt +++ b/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/RealtimePipelineTest.kt @@ -47,7 +47,9 @@ import com.google.firebase.firestore.pipeline.Expression.Companion.log10 import com.google.firebase.firestore.pipeline.Expression.Companion.mod import com.google.firebase.firestore.pipeline.Expression.Companion.multiply import com.google.firebase.firestore.pipeline.Expression.Companion.not +import com.google.firebase.firestore.pipeline.Expression.Companion.notEqual import com.google.firebase.firestore.pipeline.Expression.Companion.notEqualAny +import com.google.firebase.firestore.pipeline.Expression.Companion.nullValue import com.google.firebase.firestore.pipeline.Expression.Companion.or import com.google.firebase.firestore.pipeline.Expression.Companion.pow import com.google.firebase.firestore.pipeline.Expression.Companion.regexContains @@ -593,7 +595,7 @@ class RealtimePipelineTest { collRef.document("book1").update("title", FieldValue.serverTimestamp()) val pipeline = - db.realtimePipeline().collection(collRef.path).where(field("title").isNotNull()).limit(1) + db.realtimePipeline().collection(collRef.path).where(field("title").notEqual(nullValue())).limit(1) val channel1 = Channel(Channel.UNLIMITED) val job1 = launch { @@ -988,7 +990,7 @@ class RealtimePipelineTest { // Test isNotNull val pipelineIsNotNull = - db.realtimePipeline().collection(collRef.path).where(isNotNull("rating")) + db.realtimePipeline().collection(collRef.path).where(notEqual("rating", nullValue())) val channelIsNotNull = Channel(Channel.UNLIMITED) val jobIsNotNull = launch { From cdf2f47058f4167db46980d7b8010f7f3a67bf93 Mon Sep 17 00:00:00 2001 From: cherylEnkidu Date: Thu, 12 Feb 2026 16:55:14 -0500 Subject: [PATCH 13/53] remove nan/null operation --- .../firestore/RealtimePipelineTest.kt | 22 ++- .../firestore/pipeline/expressions.kt | 169 ------------------ 2 files changed, 13 insertions(+), 178 deletions(-) diff --git a/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/RealtimePipelineTest.kt b/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/RealtimePipelineTest.kt index dbc2690d3e2..268e3670a0b 100644 --- a/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/RealtimePipelineTest.kt +++ b/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/RealtimePipelineTest.kt @@ -36,10 +36,6 @@ import com.google.firebase.firestore.pipeline.Expression.Companion.exp import com.google.firebase.firestore.pipeline.Expression.Companion.field import com.google.firebase.firestore.pipeline.Expression.Companion.floor import com.google.firebase.firestore.pipeline.Expression.Companion.isAbsent -import com.google.firebase.firestore.pipeline.Expression.Companion.isNan -import com.google.firebase.firestore.pipeline.Expression.Companion.isNotNan -import com.google.firebase.firestore.pipeline.Expression.Companion.isNotNull -import com.google.firebase.firestore.pipeline.Expression.Companion.isNull import com.google.firebase.firestore.pipeline.Expression.Companion.like import com.google.firebase.firestore.pipeline.Expression.Companion.ln import com.google.firebase.firestore.pipeline.Expression.Companion.log @@ -575,7 +571,8 @@ class RealtimePipelineTest { collRef.document("book1").update("title", FieldValue.serverTimestamp()) - val pipeline = db.realtimePipeline().collection(collRef.path).where(field("title").isNull()) + val pipeline = + db.realtimePipeline().collection(collRef.path).where(field("title").equal(nullValue())) val channel = Channel(Channel.UNLIMITED) val job = launch { pipeline.snapshots().collect { snapshot -> channel.send(snapshot) } } @@ -595,7 +592,11 @@ class RealtimePipelineTest { collRef.document("book1").update("title", FieldValue.serverTimestamp()) val pipeline = - db.realtimePipeline().collection(collRef.path).where(field("title").notEqual(nullValue())).limit(1) + db + .realtimePipeline() + .collection(collRef.path) + .where(field("title").notEqual(nullValue())) + .limit(1) val channel1 = Channel(Channel.UNLIMITED) val job1 = launch { @@ -944,7 +945,8 @@ class RealtimePipelineTest { collRef.document("book1").update("rating", Double.NaN).await() // Test isNan - val pipelineIsNan = db.realtimePipeline().collection(collRef.path).where(isNan("rating")) + val pipelineIsNan = + db.realtimePipeline().collection(collRef.path).where(field("rating").equal(Double.NaN)) val channelIsNan = Channel(Channel.UNLIMITED) val jobIsNan = launch { @@ -958,7 +960,8 @@ class RealtimePipelineTest { jobIsNan.cancel() // Test isNotNan - val pipelineIsNotNan = db.realtimePipeline().collection(collRef.path).where(isNotNan("rating")) + val pipelineIsNotNan = + db.realtimePipeline().collection(collRef.path).where(field("rating").notEqual(Double.NaN)) val channelIsNotNan = Channel(Channel.UNLIMITED) val jobIsNotNan = launch { @@ -975,7 +978,8 @@ class RealtimePipelineTest { collRef.document("book1").update("rating", null).await() // Test isNull - val pipelineIsNull = db.realtimePipeline().collection(collRef.path).where(isNull("rating")) + val pipelineIsNull = + db.realtimePipeline().collection(collRef.path).where(field("rating").equal(nullValue())) val channelIsNull = Channel(Channel.UNLIMITED) val jobIsNull = launch { diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/expressions.kt b/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/expressions.kt index 053921f5cfd..2c271810084 100644 --- a/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/expressions.kt +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/expressions.kt @@ -1755,126 +1755,6 @@ abstract class Expression internal constructor() { fun isAbsent(fieldName: String): BooleanExpression = BooleanFunctionExpression("is_absent", evaluateIsAbsent, fieldName) - /** - * Creates an expression that checks if an expression evaluates to 'NaN' (Not a Number). - * - * ```kotlin - * // Check if the result of a calculation is NaN - * isNan(divide("value", 0)) - * ``` - * - * @param expr The expression to check. - * @return A new [BooleanExpression] representing the isNan operation. - */ - @JvmStatic - internal fun isNan(expr: Expression): BooleanExpression = - BooleanFunctionExpression("is_nan", evaluateIsNaN, expr) - - /** - * Creates an expression that checks if the field's value evaluates to 'NaN' (Not a Number). - * - * ```kotlin - * // Check if the value of a field is NaN - * isNan("value") - * ``` - * - * @param fieldName The field to check. - * @return A new [BooleanExpression] representing the isNan operation. - */ - @JvmStatic - internal fun isNan(fieldName: String): BooleanExpression = - BooleanFunctionExpression("is_nan", evaluateIsNaN, fieldName) - - /** - * Creates an expression that checks if the results of [expr] is NOT 'NaN' (Not a Number). - * - * ```kotlin - * // Check if the result of a calculation is NOT NaN - * isNotNan(divide("value", 0)) - * ``` - * - * @param expr The expression to check. - * @return A new [BooleanExpression] representing the isNotNan operation. - */ - @JvmStatic - internal fun isNotNan(expr: Expression): BooleanExpression = - BooleanFunctionExpression("is_not_nan", evaluateIsNotNaN, expr) - - /** - * Creates an expression that checks if the field's value is NOT 'NaN' (Not a Number). - * - * ```kotlin - * // Check if the value of a field is NOT NaN - * isNotNan("value") - * ``` - * - * @param fieldName The field to check. - * @return A new [BooleanExpression] representing the isNotNan operation. - */ - @JvmStatic - internal fun isNotNan(fieldName: String): BooleanExpression = - BooleanFunctionExpression("is_not_nan", evaluateIsNotNaN, fieldName) - - /** - * Creates an expression that checks if the result of [expr] is null. - * - * ```kotlin - * // Check if the value of the 'name' field is null - * isNull("name") - * ``` - * - * @param expr The expression to check. - * @return A new [BooleanExpression] representing the isNull operation. - */ - @JvmStatic - internal fun isNull(expr: Expression): BooleanExpression = - BooleanFunctionExpression("is_null", evaluateIsNull, expr) - - /** - * Creates an expression that checks if the value of a field is null. - * - * ```kotlin - * // Check if the value of the 'name' field is null - * isNull("name") - * ``` - * - * @param fieldName The field to check. - * @return A new [BooleanExpression] representing the isNull operation. - */ - @JvmStatic - internal fun isNull(fieldName: String): BooleanExpression = - BooleanFunctionExpression("is_null", evaluateIsNull, fieldName) - - /** - * Creates an expression that checks if the result of [expr] is not null. - * - * ```kotlin - * // Check if the value of the 'name' field is not null - * isNotNull(field("name")) - * ``` - * - * @param expr The expression to check. - * @return A new [BooleanExpression] representing the isNotNull operation. - */ - @JvmStatic - internal fun isNotNull(expr: Expression): BooleanExpression = - BooleanFunctionExpression("is_not_null", evaluateIsNotNull, expr) - - /** - * Creates an expression that checks if the value of a field is not null. - * - * ```kotlin - * // Check if the value of the 'name' field is not null - * isNotNull("name") - * ``` - * - * @param fieldName The field to check. - * @return A new [BooleanExpression] representing the isNotNull operation. - */ - @JvmStatic - internal fun isNotNull(fieldName: String): BooleanExpression = - BooleanFunctionExpression("is_not_null", evaluateIsNotNull, fieldName) - /** * Creates an expression that returns a string indicating the type of the value this expression * evaluates to. @@ -5951,55 +5831,6 @@ abstract class Expression internal constructor() { */ fun isAbsent(): BooleanExpression = Companion.isAbsent(this) - /** - * Creates an expression that checks if this expression evaluates to 'NaN' (Not a Number). - * - * ```kotlin - * // Check if the result of a calculation is NaN - * divide("value", 0).isNan() - * ``` - * - * @return A new [BooleanExpression] representing the isNan operation. - */ - internal fun isNan(): BooleanExpression = Companion.isNan(this) - - /** - * Creates an expression that checks if the results of this expression is NOT 'NaN' (Not a - * Number). - * - * ```kotlin - * // Check if the result of a calculation is NOT NaN - * divide("value", 0).isNotNan() - * ``` - * - * @return A new [BooleanExpression] representing the isNotNan operation. - */ - internal fun isNotNan(): BooleanExpression = Companion.isNotNan(this) - - /** - * Creates an expression that checks if the result of this expression is null. - * - * ```kotlin - * // Check if the value of the 'name' field is null - * field("name").isNull() - * ``` - * - * @return A new [BooleanExpression] representing the isNull operation. - */ - internal fun isNull(): BooleanExpression = Companion.isNull(this) - - /** - * Creates an expression that checks if the result of this expression is not null. - * - * ```kotlin - * // Check if the value of the 'name' field is not null - * field("name").isNotNull() - * ``` - * - * @return A new [BooleanExpression] representing the isNotNull operation. - */ - internal fun isNotNull(): BooleanExpression = Companion.isNotNull(this) - /** * Creates an expression that calculates the length of a string, array, map, vector, or blob * expression. From 27eb15d536c82fac0fe82afb83b31a74be15b0e2 Mon Sep 17 00:00:00 2001 From: cherylEnkidu Date: Thu, 12 Feb 2026 17:00:06 -0500 Subject: [PATCH 14/53] Fix the return type for alias() --- .../com/google/firebase/firestore/pipeline/expressions.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/expressions.kt b/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/expressions.kt index 2c271810084..6d9fc0e2b50 100644 --- a/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/expressions.kt +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/expressions.kt @@ -5434,10 +5434,10 @@ abstract class Expression internal constructor() { * to calculated values. * * @param alias The alias to assign to this expression. - * @return A new [Selectable] (typically an [AliasedExpression]) that wraps this expression and - * associates it with the provided alias. + * @return A [AliasedExpression] that wraps this expression and associates it with the provided + * alias. */ - open fun alias(alias: String): Selectable = AliasedExpression(alias, this) + open fun alias(alias: String): AliasedExpression = AliasedExpression(alias, this) /** * Creates an expression that returns the document ID from this path expression. From 4c511dfdc548adc6d6656c78303f17173996625e Mon Sep 17 00:00:00 2001 From: cherylEnkidu Date: Thu, 12 Feb 2026 17:05:38 -0500 Subject: [PATCH 15/53] Add changelog --- firebase-firestore/CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/firebase-firestore/CHANGELOG.md b/firebase-firestore/CHANGELOG.md index 151e3ad06e5..b4bd971365e 100644 --- a/firebase-firestore/CHANGELOG.md +++ b/firebase-firestore/CHANGELOG.md @@ -2,6 +2,10 @@ - [feature] Added support for `regexFind` and `regexFindAll` Pipeline expressions. [#7669](https://github.com/firebase/firebase-android-sdk/pull/7669) +- [changed] Updated `Expression.alias()` to return `AliasedExpression`. +- [removed] Removed `isNan`, `isNotNan`, `isNull`, and `isNotNull` factory methods from `Expression`. + Use `equal(Double.NaN)`, `notEqual(Double.NaN)`, `equal(nullValue())`, and `notEqual(nullValue())` + respectively. # 26.1.0 From f8b6cc70f51d5d4c7492378c3ca4342fb0181dca Mon Sep 17 00:00:00 2001 From: cherylEnkidu Date: Thu, 12 Feb 2026 17:16:07 -0500 Subject: [PATCH 16/53] Update API file --- firebase-firestore/api.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/firebase-firestore/api.txt b/firebase-firestore/api.txt index f3431ef0cf0..263882c50b0 100644 --- a/firebase-firestore/api.txt +++ b/firebase-firestore/api.txt @@ -796,7 +796,7 @@ package com.google.firebase.firestore.pipeline { method public final com.google.firebase.firestore.pipeline.Expression add(Number second); method public static final com.google.firebase.firestore.pipeline.Expression add(String numericFieldName, com.google.firebase.firestore.pipeline.Expression second); method public static final com.google.firebase.firestore.pipeline.Expression add(String numericFieldName, Number second); - method public com.google.firebase.firestore.pipeline.Selectable alias(String alias); + method public com.google.firebase.firestore.pipeline.AliasedExpression alias(String alias); method public static final com.google.firebase.firestore.pipeline.BooleanExpression and(com.google.firebase.firestore.pipeline.BooleanExpression condition, com.google.firebase.firestore.pipeline.BooleanExpression... conditions); method public static final com.google.firebase.firestore.pipeline.Expression array(java.lang.Object?... elements); method public static final com.google.firebase.firestore.pipeline.Expression array(java.util.List elements); From a7da1c304a5fd60a77189c88fa04c770d30d7b85 Mon Sep 17 00:00:00 2001 From: cherylEnkidu Date: Thu, 12 Feb 2026 17:41:24 -0500 Subject: [PATCH 17/53] Update the style --- .../java/com/google/firebase/firestore/RealtimePipelineTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/RealtimePipelineTest.kt b/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/RealtimePipelineTest.kt index 268e3670a0b..991b61d6b2c 100644 --- a/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/RealtimePipelineTest.kt +++ b/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/RealtimePipelineTest.kt @@ -994,7 +994,7 @@ class RealtimePipelineTest { // Test isNotNull val pipelineIsNotNull = - db.realtimePipeline().collection(collRef.path).where(notEqual("rating", nullValue())) + db.realtimePipeline().collection(collRef.path).where(field("rating").notEqual(nullValue())) val channelIsNotNull = Channel(Channel.UNLIMITED) val jobIsNotNull = launch { From 5063b0940258fd5caf526c9d3c88fc6e98b93f91 Mon Sep 17 00:00:00 2001 From: cherylEnkidu Date: Fri, 13 Feb 2026 14:18:21 -0500 Subject: [PATCH 18/53] fix unit tests --- .../firestore/pipeline/DisjunctiveTests.kt | 39 ++++---- .../pipeline/NestedPropertiesTests.kt | 9 +- .../firestore/pipeline/NullSemanticsTests.kt | 17 +++- .../pipeline/evaluation/logical/IsNanTests.kt | 93 ------------------- .../evaluation/logical/IsNotNullTests.kt | 77 --------------- .../evaluation/logical/IsNullTests.kt | 77 --------------- 6 files changed, 41 insertions(+), 271 deletions(-) delete mode 100644 firebase-firestore/src/test/java/com/google/firebase/firestore/pipeline/evaluation/logical/IsNanTests.kt delete mode 100644 firebase-firestore/src/test/java/com/google/firebase/firestore/pipeline/evaluation/logical/IsNotNullTests.kt delete mode 100644 firebase-firestore/src/test/java/com/google/firebase/firestore/pipeline/evaluation/logical/IsNullTests.kt diff --git a/firebase-firestore/src/test/java/com/google/firebase/firestore/pipeline/DisjunctiveTests.kt b/firebase-firestore/src/test/java/com/google/firebase/firestore/pipeline/DisjunctiveTests.kt index c0a8ed7814a..828c740614f 100644 --- a/firebase-firestore/src/test/java/com/google/firebase/firestore/pipeline/DisjunctiveTests.kt +++ b/firebase-firestore/src/test/java/com/google/firebase/firestore/pipeline/DisjunctiveTests.kt @@ -20,11 +20,8 @@ import com.google.firebase.firestore.TestUtil import com.google.firebase.firestore.pipeline.Expression.Companion.and import com.google.firebase.firestore.pipeline.Expression.Companion.array import com.google.firebase.firestore.pipeline.Expression.Companion.constant +import com.google.firebase.firestore.pipeline.Expression.Companion.exists import com.google.firebase.firestore.pipeline.Expression.Companion.field -import com.google.firebase.firestore.pipeline.Expression.Companion.isNan -import com.google.firebase.firestore.pipeline.Expression.Companion.isNull -import com.google.firebase.firestore.pipeline.Expression.Companion.not -import com.google.firebase.firestore.pipeline.Expression.Companion.nullValue import com.google.firebase.firestore.pipeline.Expression.Companion.or import com.google.firebase.firestore.runPipeline import com.google.firebase.firestore.testutil.TestUtilKtx.doc @@ -1032,7 +1029,7 @@ internal class DisjunctiveTests { } @Test - fun `or isNull and eq on same field`(): Unit = runBlocking { + fun `or eq null and eq on same field`(): Unit = runBlocking { val doc1 = doc("users/a", 1000, mapOf("a" to 1L)) val doc2 = doc("users/b", 1000, mapOf("a" to 1.0)) val doc3 = doc("users/c", 1000, mapOf("a" to 1L, "b" to 1L)) @@ -1044,7 +1041,7 @@ internal class DisjunctiveTests { val pipeline = RealtimePipelineSource(db) .collection("/users") - .where(or(field("a").equal(constant(1L)), isNull(field("a")))) + .where(or(field("a").equal(constant(1L)), field("a").equal(Expression.nullValue()))) val result = runPipeline(pipeline, listOf(*documents.toTypedArray())).toList() // C++ test expects 1.0 to match 1L in this context. @@ -1053,7 +1050,7 @@ internal class DisjunctiveTests { } @Test - fun `or isNull and eq on different field`(): Unit = runBlocking { + fun `or eq null and eq on different field`(): Unit = runBlocking { val doc1 = doc("users/a", 1000, mapOf("a" to 1L)) val doc2 = doc("users/b", 1000, mapOf("a" to 1.0)) val doc3 = doc("users/c", 1000, mapOf("a" to 1L, "b" to 1L)) @@ -1065,14 +1062,14 @@ internal class DisjunctiveTests { val pipeline = RealtimePipelineSource(db) .collection("/users") - .where(or(field("b").equal(constant(1L)), isNull(field("a")))) + .where(or(field("b").equal(constant(1L)), field("a").equal(Expression.nullValue()))) val result = runPipeline(pipeline, listOf(*documents.toTypedArray())).toList() assertThat(result).containsExactlyElementsIn(listOf(doc3, doc4)) } @Test - fun `or isNotNull and eq on same field`(): Unit = runBlocking { + fun `or not eq null and eq on same field`(): Unit = runBlocking { val doc1 = doc("users/a", 1000, mapOf("a" to 1L)) val doc2 = doc("users/b", 1000, mapOf("a" to 1.0)) val doc3 = doc("users/c", 1000, mapOf("a" to 1L, "b" to 1L)) @@ -1084,7 +1081,12 @@ internal class DisjunctiveTests { val pipeline = RealtimePipelineSource(db) .collection("/users") - .where(or(field("a").greaterThan(constant(1L)), not(isNull(field("a"))))) + .where( + or( + field("a").greaterThan(constant(1L)), + and(exists(field("a")), field("a").notEqual(Expression.nullValue())) + ) + ) val result = runPipeline(pipeline, listOf(*documents.toTypedArray())).toList() // a > 1L (none) OR a IS NOT NULL (doc1, doc2, doc3, doc5) @@ -1092,7 +1094,7 @@ internal class DisjunctiveTests { } @Test - fun `or isNotNull and eq on different field`(): Unit = runBlocking { + fun `or not eq null and eq on different field`(): Unit = runBlocking { val doc1 = doc("users/a", 1000, mapOf("a" to 1L)) val doc2 = doc("users/b", 1000, mapOf("a" to 1.0)) val doc3 = doc("users/c", 1000, mapOf("a" to 1L, "b" to 1L)) @@ -1104,7 +1106,12 @@ internal class DisjunctiveTests { val pipeline = RealtimePipelineSource(db) .collection("/users") - .where(or(field("b").equal(constant(1L)), not(isNull(field("a"))))) + .where( + or( + field("b").equal(constant(1L)), + and(exists(field("a")), field("a").notEqual(Expression.nullValue())) + ) + ) val result = runPipeline(pipeline, listOf(*documents.toTypedArray())).toList() // b == 1L (doc3) OR a IS NOT NULL (doc1, doc2, doc3, doc5) @@ -1112,7 +1119,7 @@ internal class DisjunctiveTests { } @Test - fun `or isNull and isNaN on same field`(): Unit = runBlocking { + fun `or eq null and eq NaN on same field`(): Unit = runBlocking { val doc1 = doc("users/a", 1000, mapOf("a" to null)) val doc2 = doc("users/b", 1000, mapOf("a" to Double.NaN)) val doc3 = doc("users/c", 1000, mapOf("a" to "abc")) @@ -1121,14 +1128,14 @@ internal class DisjunctiveTests { val pipeline = RealtimePipelineSource(db) .collection("/users") - .where(or(isNull(field("a")), isNan(field("a")))) + .where(or(field("a").equal(Expression.nullValue()), field("a").equal(constant(Double.NaN)))) val result = runPipeline(pipeline, listOf(*documents.toTypedArray())).toList() assertThat(result).containsExactlyElementsIn(listOf(doc1, doc2)) } @Test - fun `or is null and is nan on different field`(): Unit = runBlocking { + fun `or eq null and eq NaN on different field`(): Unit = runBlocking { val doc1 = doc("users/a", 1000, mapOf("a" to null)) val doc2 = doc("users/b", 1000, mapOf("a" to Double.NaN)) val doc3 = doc("users/c", 1000, mapOf("a" to "abc")) @@ -1140,7 +1147,7 @@ internal class DisjunctiveTests { val pipeline = RealtimePipelineSource(db) .collection("/users") - .where(or(field("a").equal(nullValue()), field("b").equal(Double.NaN))) + .where(or(field("a").equal(Expression.nullValue()), field("b").equal(constant(Double.NaN)))) val result = runPipeline(pipeline, listOf(*documents.toTypedArray())).toList() assertThat(result).containsExactlyElementsIn(listOf(doc1, doc5)) diff --git a/firebase-firestore/src/test/java/com/google/firebase/firestore/pipeline/NestedPropertiesTests.kt b/firebase-firestore/src/test/java/com/google/firebase/firestore/pipeline/NestedPropertiesTests.kt index c41d5941a6d..6b32f920ebd 100644 --- a/firebase-firestore/src/test/java/com/google/firebase/firestore/pipeline/NestedPropertiesTests.kt +++ b/firebase-firestore/src/test/java/com/google/firebase/firestore/pipeline/NestedPropertiesTests.kt @@ -18,12 +18,13 @@ import com.google.common.truth.Truth.assertThat import com.google.firebase.firestore.FieldPath as PublicFieldPath import com.google.firebase.firestore.RealtimePipelineSource import com.google.firebase.firestore.TestUtil +import com.google.firebase.firestore.pipeline.Expression.Companion.and import com.google.firebase.firestore.pipeline.Expression.Companion.constant import com.google.firebase.firestore.pipeline.Expression.Companion.exists import com.google.firebase.firestore.pipeline.Expression.Companion.field -import com.google.firebase.firestore.pipeline.Expression.Companion.isNull import com.google.firebase.firestore.pipeline.Expression.Companion.map import com.google.firebase.firestore.pipeline.Expression.Companion.not +import com.google.firebase.firestore.pipeline.Expression.Companion.nullValue import com.google.firebase.firestore.runPipeline import com.google.firebase.firestore.testutil.TestUtilKtx.doc import kotlinx.coroutines.runBlocking @@ -521,7 +522,7 @@ internal class NestedPropertiesTests { val documents = listOf(doc1, doc2, doc3) val pipeline = - RealtimePipelineSource(db).collection("/users").where(isNull(field("address.street"))) + RealtimePipelineSource(db).collection("/users").where(field("address.street").equal(nullValue())) val result = runPipeline(pipeline, listOf(*documents.toTypedArray())).toList() assertThat(result).containsExactly(doc1) @@ -556,7 +557,9 @@ internal class NestedPropertiesTests { val documents = listOf(doc1, doc2, doc3) val pipeline = - RealtimePipelineSource(db).collection("/users").where(not(isNull(field("address.street")))) + RealtimePipelineSource(db) + .collection("/users") + .where(and(exists(field("address.street")), field("address.street").notEqual(nullValue()))) val result = runPipeline(pipeline, listOf(*documents.toTypedArray())).toList() assertThat(result).containsExactly(doc2) diff --git a/firebase-firestore/src/test/java/com/google/firebase/firestore/pipeline/NullSemanticsTests.kt b/firebase-firestore/src/test/java/com/google/firebase/firestore/pipeline/NullSemanticsTests.kt index 864a3f86bfb..0c4aa93fbe2 100644 --- a/firebase-firestore/src/test/java/com/google/firebase/firestore/pipeline/NullSemanticsTests.kt +++ b/firebase-firestore/src/test/java/com/google/firebase/firestore/pipeline/NullSemanticsTests.kt @@ -25,12 +25,11 @@ import com.google.firebase.firestore.pipeline.Expression.Companion.arrayContains import com.google.firebase.firestore.pipeline.Expression.Companion.constant import com.google.firebase.firestore.pipeline.Expression.Companion.equal import com.google.firebase.firestore.pipeline.Expression.Companion.equalAny +import com.google.firebase.firestore.pipeline.Expression.Companion.exists import com.google.firebase.firestore.pipeline.Expression.Companion.field import com.google.firebase.firestore.pipeline.Expression.Companion.greaterThan import com.google.firebase.firestore.pipeline.Expression.Companion.greaterThanOrEqual import com.google.firebase.firestore.pipeline.Expression.Companion.isError -import com.google.firebase.firestore.pipeline.Expression.Companion.isNotNull -import com.google.firebase.firestore.pipeline.Expression.Companion.isNull import com.google.firebase.firestore.pipeline.Expression.Companion.lessThan import com.google.firebase.firestore.pipeline.Expression.Companion.lessThanOrEqual import com.google.firebase.firestore.pipeline.Expression.Companion.map @@ -66,7 +65,7 @@ internal class NullSemanticsTests { val doc7 = doc("users/7", 1000, mapOf("not-score" to 42L)) // score: missing val documents = listOf(doc1, doc2, doc3, doc4, doc5, doc6, doc7) - val pipeline = RealtimePipelineSource(db).collection("users").where(isNull(field("score"))) + val pipeline = RealtimePipelineSource(db).collection("users").where(equal(field("score"), nullValue())) val result = runPipeline(pipeline, listOf(*documents.toTypedArray())).toList() assertThat(result).containsExactly(doc1) @@ -84,7 +83,10 @@ internal class NullSemanticsTests { val doc7 = doc("users/7", 1000, mapOf("not-score" to 42L)) // score: missing val documents = listOf(doc1, doc2, doc3, doc4, doc5, doc6, doc7) - val pipeline = RealtimePipelineSource(db).collection("users").where(isNotNull(field("score"))) + val pipeline = + RealtimePipelineSource(db) + .collection("users") + .where(and(exists(field("score")), field("score").notEqual(nullValue()))) val result = runPipeline(pipeline, listOf(*documents.toTypedArray())).toList() assertThat(result).containsExactlyElementsIn(listOf(doc2, doc3, doc4, doc5, doc6)) @@ -101,7 +103,12 @@ internal class NullSemanticsTests { val pipeline = RealtimePipelineSource(db) .collection("users") - .where(and(isNull(field("score")), isNotNull(field("score")))) + .where( + and( + equal(field("score"), nullValue()), + and(exists(field("score")), field("score").notEqual(nullValue())) + ) + ) val result = runPipeline(pipeline, listOf(*documents.toTypedArray())).toList() assertThat(result).isEmpty() diff --git a/firebase-firestore/src/test/java/com/google/firebase/firestore/pipeline/evaluation/logical/IsNanTests.kt b/firebase-firestore/src/test/java/com/google/firebase/firestore/pipeline/evaluation/logical/IsNanTests.kt deleted file mode 100644 index 7ea06dd9632..00000000000 --- a/firebase-firestore/src/test/java/com/google/firebase/firestore/pipeline/evaluation/logical/IsNanTests.kt +++ /dev/null @@ -1,93 +0,0 @@ -// Copyright 2025 Google LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package com.google.firebase.firestore.pipeline.evaluation.logical - -import com.google.firebase.firestore.pipeline.Expression.Companion.add -import com.google.firebase.firestore.pipeline.Expression.Companion.array -import com.google.firebase.firestore.pipeline.Expression.Companion.constant -import com.google.firebase.firestore.pipeline.Expression.Companion.field -import com.google.firebase.firestore.pipeline.Expression.Companion.isNan -import com.google.firebase.firestore.pipeline.Expression.Companion.isNotNan -import com.google.firebase.firestore.pipeline.Expression.Companion.map -import com.google.firebase.firestore.pipeline.Expression.Companion.nullValue -import com.google.firebase.firestore.pipeline.assertEvaluatesTo -import com.google.firebase.firestore.pipeline.assertEvaluatesToError -import com.google.firebase.firestore.pipeline.assertEvaluatesToNull -import com.google.firebase.firestore.pipeline.evaluate -import com.google.firebase.firestore.testutil.TestUtilKtx.doc -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner - -@RunWith(RobolectricTestRunner::class) -class IsNanTests { - private val nullExpr = nullValue() - private val nanExpr = constant(Double.NaN) - private val testDocWithNan = - doc("coll/docNan", 1, mapOf("nanValue" to Double.NaN, "field" to "value")) - private val emptyDoc = doc("coll/docEmpty", 1, emptyMap()) - - // --- IsNan / IsNotNan Tests --- - @Test - fun `isNan - nan returns true`() { - assertEvaluatesTo(evaluate(isNan(nanExpr), emptyDoc), true, "isNan(NaN)") - assertEvaluatesTo( - evaluate(isNan(field("nanValue")), testDocWithNan), - true, - "isNan(field(nanValue))" - ) - } - - @Test - fun `isNan - not nan returns false`() { - assertEvaluatesTo(evaluate(isNan(constant(42.0)), emptyDoc), false, "isNan(42.0)") - assertEvaluatesTo(evaluate(isNan(constant(42L)), emptyDoc), false, "isNan(42L)") - } - - @Test - fun `isNotNan - not nan returns true`() { - assertEvaluatesTo(evaluate(isNotNan(constant(42.0)), emptyDoc), true, "isNotNan(42.0)") - assertEvaluatesTo(evaluate(isNotNan(constant(42L)), emptyDoc), true, "isNotNan(42L)") - } - - @Test - fun `isNotNan - nan returns false`() { - assertEvaluatesTo(evaluate(isNotNan(nanExpr), emptyDoc), false, "isNotNan(NaN)") - assertEvaluatesTo( - evaluate(isNotNan(field("nanValue")), testDocWithNan), - false, - "isNotNan(field(nanValue))" - ) - } - - @Test - fun `isNan - other nan representations returns true`() { - val nanPlusOne = add(nanExpr, constant(1L)) - assertEvaluatesTo(evaluate(isNan(nanPlusOne), emptyDoc), true, "isNan(NaN + 1)") - } - - @Test - fun `isNan - non numeric returns error`() { - assertEvaluatesToError(evaluate(isNan(constant(true)), emptyDoc), "isNan(true) should be error") - assertEvaluatesToError(evaluate(isNan(constant("abc")), emptyDoc), "isNan(abc) should be error") - assertEvaluatesToError(evaluate(isNan(array()), emptyDoc), "isNan([]) should be error") - assertEvaluatesToError(evaluate(isNan(map(emptyMap())), emptyDoc), "isNan({}) should be error") - } - - @Test - fun `isNan - null returns null`() { - assertEvaluatesToNull(evaluate(isNan(nullExpr), emptyDoc), "isNan(null) should be null") - } -} diff --git a/firebase-firestore/src/test/java/com/google/firebase/firestore/pipeline/evaluation/logical/IsNotNullTests.kt b/firebase-firestore/src/test/java/com/google/firebase/firestore/pipeline/evaluation/logical/IsNotNullTests.kt deleted file mode 100644 index 22dd9b7e7dd..00000000000 --- a/firebase-firestore/src/test/java/com/google/firebase/firestore/pipeline/evaluation/logical/IsNotNullTests.kt +++ /dev/null @@ -1,77 +0,0 @@ -// Copyright 2025 Google LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package com.google.firebase.firestore.pipeline.evaluation.logical - -import com.google.firebase.firestore.pipeline.Expression -import com.google.firebase.firestore.pipeline.Expression.Companion.array -import com.google.firebase.firestore.pipeline.Expression.Companion.constant -import com.google.firebase.firestore.pipeline.Expression.Companion.field -import com.google.firebase.firestore.pipeline.Expression.Companion.isNotNull -import com.google.firebase.firestore.pipeline.Expression.Companion.map -import com.google.firebase.firestore.pipeline.Expression.Companion.nullValue -import com.google.firebase.firestore.pipeline.assertEvaluatesTo -import com.google.firebase.firestore.pipeline.assertEvaluatesToError -import com.google.firebase.firestore.pipeline.evaluate -import com.google.firebase.firestore.testutil.TestUtilKtx.doc -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner - -@RunWith(RobolectricTestRunner::class) -class IsNotNullTests { - private val nullExpr = nullValue() - private val errorExpr = Expression.error("error.field").equal(constant("random")) - private val errorDoc = - doc("coll/docError", 1, mapOf("error" to 123)) // "error.field" will be UNSET - private val emptyDoc = doc("coll/docEmpty", 1, emptyMap()) - - // --- IsNotNull Tests --- - @Test - fun `isNotNull - null returns false`() { - val expr = isNotNull(nullExpr) - assertEvaluatesTo(evaluate(expr, emptyDoc), false, "isNotNull(null)") - } - - @Test - fun `isNotNull - error returns error`() { - val expr = isNotNull(errorExpr) - assertEvaluatesToError(evaluate(expr, errorDoc), "isNotNull(error)") - } - - @Test - fun `isNotNull - unset field returns error`() { - val expr = isNotNull(field("non-existent-field")) - assertEvaluatesToError(evaluate(expr, emptyDoc), "isNotNull(unset)") - } - - @Test - fun `isNotNull - anything but null returns true`() { - val values = - listOf( - constant(true), - constant(false), - constant(0), - constant(1.0), - constant("abc"), - constant(Double.NaN), - array(constant(1)), - map(mapOf("a" to 1)) - ) - for (valueExpr in values) { - val expr = isNotNull(valueExpr) - assertEvaluatesTo(evaluate(expr, emptyDoc), true, "isNotNull(${valueExpr})") - } - } -} diff --git a/firebase-firestore/src/test/java/com/google/firebase/firestore/pipeline/evaluation/logical/IsNullTests.kt b/firebase-firestore/src/test/java/com/google/firebase/firestore/pipeline/evaluation/logical/IsNullTests.kt deleted file mode 100644 index af3893de71a..00000000000 --- a/firebase-firestore/src/test/java/com/google/firebase/firestore/pipeline/evaluation/logical/IsNullTests.kt +++ /dev/null @@ -1,77 +0,0 @@ -// Copyright 2025 Google LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package com.google.firebase.firestore.pipeline.evaluation.logical - -import com.google.firebase.firestore.pipeline.Expression -import com.google.firebase.firestore.pipeline.Expression.Companion.array -import com.google.firebase.firestore.pipeline.Expression.Companion.constant -import com.google.firebase.firestore.pipeline.Expression.Companion.field -import com.google.firebase.firestore.pipeline.Expression.Companion.isNull -import com.google.firebase.firestore.pipeline.Expression.Companion.map -import com.google.firebase.firestore.pipeline.Expression.Companion.nullValue -import com.google.firebase.firestore.pipeline.assertEvaluatesTo -import com.google.firebase.firestore.pipeline.assertEvaluatesToError -import com.google.firebase.firestore.pipeline.evaluate -import com.google.firebase.firestore.testutil.TestUtilKtx.doc -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner - -@RunWith(RobolectricTestRunner::class) -class IsNullTests { - private val nullExpr = nullValue() - private val errorExpr = Expression.error("error.field").equal(constant("random")) - private val errorDoc = - doc("coll/docError", 1, mapOf("error" to 123)) // "error.field" will be UNSET - private val emptyDoc = doc("coll/docEmpty", 1, emptyMap()) - - // --- IsNull Tests --- - @Test - fun `isNull - null returns true`() { - val expr = isNull(nullExpr) - assertEvaluatesTo(evaluate(expr, emptyDoc), true, "isNull(null)") - } - - @Test - fun `isNull - error returns error`() { - val expr = isNull(errorExpr) - assertEvaluatesToError(evaluate(expr, errorDoc), "isNull(error)") - } - - @Test - fun `isNull - unset field returns error`() { - val expr = isNull(field("non-existent-field")) - assertEvaluatesToError(evaluate(expr, emptyDoc), "isNull(unset)") - } - - @Test - fun `isNull - anything but null returns false`() { - val values = - listOf( - constant(true), - constant(false), - constant(0), - constant(1.0), - constant("abc"), - constant(Double.NaN), - array(constant(1)), - map(mapOf("a" to 1)) - ) - for (valueExpr in values) { - val expr = isNull(valueExpr) - assertEvaluatesTo(evaluate(expr, emptyDoc), false, "isNull(${valueExpr})") - } - } -} From c94879d18a92eb30a6b2c5da0a4df7142d5f8fb6 Mon Sep 17 00:00:00 2001 From: cherylEnkidu Date: Fri, 13 Feb 2026 14:23:21 -0500 Subject: [PATCH 19/53] format --- .../firebase/firestore/pipeline/NestedPropertiesTests.kt | 4 +++- .../google/firebase/firestore/pipeline/NullSemanticsTests.kt | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/firebase-firestore/src/test/java/com/google/firebase/firestore/pipeline/NestedPropertiesTests.kt b/firebase-firestore/src/test/java/com/google/firebase/firestore/pipeline/NestedPropertiesTests.kt index 6b32f920ebd..fa86d43cb67 100644 --- a/firebase-firestore/src/test/java/com/google/firebase/firestore/pipeline/NestedPropertiesTests.kt +++ b/firebase-firestore/src/test/java/com/google/firebase/firestore/pipeline/NestedPropertiesTests.kt @@ -522,7 +522,9 @@ internal class NestedPropertiesTests { val documents = listOf(doc1, doc2, doc3) val pipeline = - RealtimePipelineSource(db).collection("/users").where(field("address.street").equal(nullValue())) + RealtimePipelineSource(db) + .collection("/users") + .where(field("address.street").equal(nullValue())) val result = runPipeline(pipeline, listOf(*documents.toTypedArray())).toList() assertThat(result).containsExactly(doc1) diff --git a/firebase-firestore/src/test/java/com/google/firebase/firestore/pipeline/NullSemanticsTests.kt b/firebase-firestore/src/test/java/com/google/firebase/firestore/pipeline/NullSemanticsTests.kt index 0c4aa93fbe2..8ff7b7b7119 100644 --- a/firebase-firestore/src/test/java/com/google/firebase/firestore/pipeline/NullSemanticsTests.kt +++ b/firebase-firestore/src/test/java/com/google/firebase/firestore/pipeline/NullSemanticsTests.kt @@ -65,7 +65,8 @@ internal class NullSemanticsTests { val doc7 = doc("users/7", 1000, mapOf("not-score" to 42L)) // score: missing val documents = listOf(doc1, doc2, doc3, doc4, doc5, doc6, doc7) - val pipeline = RealtimePipelineSource(db).collection("users").where(equal(field("score"), nullValue())) + val pipeline = + RealtimePipelineSource(db).collection("users").where(equal(field("score"), nullValue())) val result = runPipeline(pipeline, listOf(*documents.toTypedArray())).toList() assertThat(result).containsExactly(doc1) From 1fed73e434212984f601baa395df9b67eac5001a Mon Sep 17 00:00:00 2001 From: cherylEnkidu <96084918+cherylEnkidu@users.noreply.github.com> Date: Fri, 13 Feb 2026 14:50:05 -0500 Subject: [PATCH 20/53] Update firebase-firestore/src/test/java/com/google/firebase/firestore/pipeline/DisjunctiveTests.kt Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- .../com/google/firebase/firestore/pipeline/DisjunctiveTests.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/firebase-firestore/src/test/java/com/google/firebase/firestore/pipeline/DisjunctiveTests.kt b/firebase-firestore/src/test/java/com/google/firebase/firestore/pipeline/DisjunctiveTests.kt index 828c740614f..863d983cd9f 100644 --- a/firebase-firestore/src/test/java/com/google/firebase/firestore/pipeline/DisjunctiveTests.kt +++ b/firebase-firestore/src/test/java/com/google/firebase/firestore/pipeline/DisjunctiveTests.kt @@ -1041,7 +1041,7 @@ internal class DisjunctiveTests { val pipeline = RealtimePipelineSource(db) .collection("/users") - .where(or(field("a").equal(constant(1L)), field("a").equal(Expression.nullValue()))) + .where(or(field("a").equal(constant(1L)), field("a").equal(nullValue()))) val result = runPipeline(pipeline, listOf(*documents.toTypedArray())).toList() // C++ test expects 1.0 to match 1L in this context. From 4190e12a47efa5e4ba1dd077f2f8105306e1122b Mon Sep 17 00:00:00 2001 From: cherylEnkidu Date: Fri, 13 Feb 2026 15:11:46 -0500 Subject: [PATCH 21/53] fix bug --- .../com/google/firebase/firestore/pipeline/DisjunctiveTests.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/firebase-firestore/src/test/java/com/google/firebase/firestore/pipeline/DisjunctiveTests.kt b/firebase-firestore/src/test/java/com/google/firebase/firestore/pipeline/DisjunctiveTests.kt index 863d983cd9f..828c740614f 100644 --- a/firebase-firestore/src/test/java/com/google/firebase/firestore/pipeline/DisjunctiveTests.kt +++ b/firebase-firestore/src/test/java/com/google/firebase/firestore/pipeline/DisjunctiveTests.kt @@ -1041,7 +1041,7 @@ internal class DisjunctiveTests { val pipeline = RealtimePipelineSource(db) .collection("/users") - .where(or(field("a").equal(constant(1L)), field("a").equal(nullValue()))) + .where(or(field("a").equal(constant(1L)), field("a").equal(Expression.nullValue()))) val result = runPipeline(pipeline, listOf(*documents.toTypedArray())).toList() // C++ test expects 1.0 to match 1L in this context. From 1465bdfafa27302147bfa2f460151a6a8009737a Mon Sep 17 00:00:00 2001 From: cherylEnkidu Date: Fri, 13 Feb 2026 17:12:12 -0500 Subject: [PATCH 22/53] add copyright and API changes txt file --- firebase-firestore/api.txt | 48 +++++++++++++------ .../pipeline/SubqueryPipelineTests.kt | 14 ++++++ 2 files changed, 47 insertions(+), 15 deletions(-) diff --git a/firebase-firestore/api.txt b/firebase-firestore/api.txt index 3aca558f6d0..d597d0cd099 100644 --- a/firebase-firestore/api.txt +++ b/firebase-firestore/api.txt @@ -424,6 +424,7 @@ package com.google.firebase.firestore { method public com.google.firebase.firestore.Pipeline aggregate(com.google.firebase.firestore.pipeline.AggregateStage aggregateStage); method public com.google.firebase.firestore.Pipeline aggregate(com.google.firebase.firestore.pipeline.AggregateStage aggregateStage, com.google.firebase.firestore.pipeline.AggregateOptions options); method public com.google.firebase.firestore.Pipeline aggregate(com.google.firebase.firestore.pipeline.AliasedAggregate accumulator, com.google.firebase.firestore.pipeline.AliasedAggregate... additionalAccumulators); + method public com.google.firebase.firestore.Pipeline define(com.google.firebase.firestore.pipeline.AliasedExpression aliasedExpression, com.google.firebase.firestore.pipeline.AliasedExpression... additionalExpressions); method public com.google.firebase.firestore.Pipeline distinct(com.google.firebase.firestore.pipeline.Selectable group, java.lang.Object... additionalGroups); method public com.google.firebase.firestore.Pipeline distinct(String groupField, java.lang.Object... additionalGroups); method public com.google.android.gms.tasks.Task execute(); @@ -443,12 +444,20 @@ package com.google.firebase.firestore { method public com.google.firebase.firestore.Pipeline select(com.google.firebase.firestore.pipeline.Selectable selection, java.lang.Object... additionalSelections); method public com.google.firebase.firestore.Pipeline select(String fieldName, java.lang.Object... additionalSelections); method public com.google.firebase.firestore.Pipeline sort(com.google.firebase.firestore.pipeline.Ordering order, com.google.firebase.firestore.pipeline.Ordering... additionalOrders); + method public static com.google.firebase.firestore.Pipeline subcollection(String path); + method public com.google.firebase.firestore.pipeline.Expression toArrayExpression(); + method public com.google.firebase.firestore.pipeline.Expression toScalarExpression(); method public com.google.firebase.firestore.Pipeline union(com.google.firebase.firestore.Pipeline other); method public com.google.firebase.firestore.Pipeline unnest(com.google.firebase.firestore.pipeline.Selectable arrayWithAlias); method public com.google.firebase.firestore.Pipeline unnest(com.google.firebase.firestore.pipeline.Selectable arrayWithAlias, com.google.firebase.firestore.pipeline.UnnestOptions options); method public com.google.firebase.firestore.Pipeline unnest(com.google.firebase.firestore.pipeline.UnnestStage unnestStage); method public com.google.firebase.firestore.Pipeline unnest(String arrayField, String alias); method public com.google.firebase.firestore.Pipeline where(com.google.firebase.firestore.pipeline.BooleanExpression condition); + field public static final com.google.firebase.firestore.Pipeline.Companion Companion; + } + + public static final class Pipeline.Companion { + method public com.google.firebase.firestore.Pipeline subcollection(String path); } public static final class Pipeline.ExecuteOptions extends com.google.firebase.firestore.pipeline.AbstractOptions { @@ -711,17 +720,17 @@ package com.google.firebase.firestore.pipeline { method public com.google.firebase.firestore.pipeline.AggregateFunction sum(String fieldName); } - public final class AggregateHints extends com.google.firebase.firestore.pipeline.AbstractOptions { + @com.google.common.annotations.Beta public final class AggregateHints extends com.google.firebase.firestore.pipeline.AbstractOptions { ctor public AggregateHints(); method public com.google.firebase.firestore.pipeline.AggregateHints withForceStreamableEnabled(); } - public final class AggregateOptions extends com.google.firebase.firestore.pipeline.AbstractOptions { + @com.google.common.annotations.Beta public final class AggregateOptions extends com.google.firebase.firestore.pipeline.AbstractOptions { ctor public AggregateOptions(); method public com.google.firebase.firestore.pipeline.AggregateOptions withHints(com.google.firebase.firestore.pipeline.AggregateHints hints); } - public final class AggregateStage extends com.google.firebase.firestore.pipeline.Stage { + @com.google.common.annotations.Beta public final class AggregateStage extends com.google.firebase.firestore.pipeline.Stage { method public static com.google.firebase.firestore.pipeline.AggregateStage withAccumulators(com.google.firebase.firestore.pipeline.AliasedAggregate accumulator, com.google.firebase.firestore.pipeline.AliasedAggregate... additionalAccumulators); method public com.google.firebase.firestore.pipeline.AggregateStage withGroups(com.google.firebase.firestore.pipeline.Selectable group, java.lang.Object... additionalGroups); method public com.google.firebase.firestore.pipeline.AggregateStage withGroups(String groupField, java.lang.Object... additionalGroups); @@ -756,26 +765,26 @@ package com.google.firebase.firestore.pipeline { method public com.google.firebase.firestore.pipeline.BooleanExpression rawFunction(String name, com.google.firebase.firestore.pipeline.Expression... expr); } - public final class CollectionGroupOptions extends com.google.firebase.firestore.pipeline.AbstractOptions { + @com.google.common.annotations.Beta public final class CollectionGroupOptions extends com.google.firebase.firestore.pipeline.AbstractOptions { ctor public CollectionGroupOptions(); method public com.google.firebase.firestore.pipeline.CollectionGroupOptions withHints(com.google.firebase.firestore.pipeline.CollectionHints hints); } - public final class CollectionGroupSource extends com.google.firebase.firestore.pipeline.Stage { + @com.google.common.annotations.Beta public final class CollectionGroupSource extends com.google.firebase.firestore.pipeline.Stage { method public String getCollectionId(); property public final String collectionId; } - public final class CollectionHints extends com.google.firebase.firestore.pipeline.AbstractOptions { + @com.google.common.annotations.Beta public final class CollectionHints extends com.google.firebase.firestore.pipeline.AbstractOptions { ctor public CollectionHints(); method public com.google.firebase.firestore.pipeline.CollectionHints withForceIndex(String value); method public com.google.firebase.firestore.pipeline.CollectionHints withIgnoreIndexFields(java.lang.String... values); } - public final class CollectionSource extends com.google.firebase.firestore.pipeline.Stage { + @com.google.common.annotations.Beta public final class CollectionSource extends com.google.firebase.firestore.pipeline.Stage { } - public final class CollectionSourceOptions extends com.google.firebase.firestore.pipeline.AbstractOptions { + @com.google.common.annotations.Beta public final class CollectionSourceOptions extends com.google.firebase.firestore.pipeline.AbstractOptions { ctor public CollectionSourceOptions(); method public com.google.firebase.firestore.pipeline.CollectionSourceOptions withHints(com.google.firebase.firestore.pipeline.CollectionHints hints); } @@ -910,6 +919,7 @@ package com.google.firebase.firestore.pipeline { method public static final com.google.firebase.firestore.pipeline.Expression cosineDistance(String vectorFieldName, double[] vector); method public final com.google.firebase.firestore.pipeline.AggregateFunction count(); method public final com.google.firebase.firestore.pipeline.AggregateFunction countDistinct(); + method public static final com.google.firebase.firestore.pipeline.Expression currentDocument(); method public static final com.google.firebase.firestore.pipeline.Expression currentTimestamp(); method public final com.google.firebase.firestore.pipeline.Ordering descending(); method public final com.google.firebase.firestore.pipeline.Expression divide(com.google.firebase.firestore.pipeline.Expression divisor); @@ -969,6 +979,7 @@ package com.google.firebase.firestore.pipeline { method public final com.google.firebase.firestore.pipeline.Expression floor(); method public static final com.google.firebase.firestore.pipeline.Expression floor(com.google.firebase.firestore.pipeline.Expression numericExpr); method public static final com.google.firebase.firestore.pipeline.Expression floor(String numericField); + method public static final com.google.firebase.firestore.pipeline.Expression getField(com.google.firebase.firestore.pipeline.Expression expression, String key); method public final com.google.firebase.firestore.pipeline.BooleanExpression greaterThan(com.google.firebase.firestore.pipeline.Expression other); method public static final com.google.firebase.firestore.pipeline.BooleanExpression greaterThan(com.google.firebase.firestore.pipeline.Expression left, com.google.firebase.firestore.pipeline.Expression right); method public static final com.google.firebase.firestore.pipeline.BooleanExpression greaterThan(com.google.firebase.firestore.pipeline.Expression left, Object right); @@ -1227,6 +1238,7 @@ package com.google.firebase.firestore.pipeline { method public final com.google.firebase.firestore.pipeline.Expression unixSecondsToTimestamp(); method public static final com.google.firebase.firestore.pipeline.Expression unixSecondsToTimestamp(com.google.firebase.firestore.pipeline.Expression expr); method public static final com.google.firebase.firestore.pipeline.Expression unixSecondsToTimestamp(String fieldName); + method public static final com.google.firebase.firestore.pipeline.Expression variable(String name); method public static final com.google.firebase.firestore.pipeline.Expression vector(com.google.firebase.firestore.VectorValue vector); method public static final com.google.firebase.firestore.pipeline.Expression vector(double[] vector); method public final com.google.firebase.firestore.pipeline.Expression vectorLength(); @@ -1324,6 +1336,7 @@ package com.google.firebase.firestore.pipeline { method public com.google.firebase.firestore.pipeline.Expression cosineDistance(String vectorFieldName, com.google.firebase.firestore.pipeline.Expression vector); method public com.google.firebase.firestore.pipeline.Expression cosineDistance(String vectorFieldName, com.google.firebase.firestore.VectorValue vector); method public com.google.firebase.firestore.pipeline.Expression cosineDistance(String vectorFieldName, double[] vector); + method public com.google.firebase.firestore.pipeline.Expression currentDocument(); method public com.google.firebase.firestore.pipeline.Expression currentTimestamp(); method public com.google.firebase.firestore.pipeline.Expression divide(com.google.firebase.firestore.pipeline.Expression dividend, com.google.firebase.firestore.pipeline.Expression divisor); method public com.google.firebase.firestore.pipeline.Expression divide(com.google.firebase.firestore.pipeline.Expression dividend, Number divisor); @@ -1364,6 +1377,7 @@ package com.google.firebase.firestore.pipeline { method public com.google.firebase.firestore.pipeline.Field field(String name); method public com.google.firebase.firestore.pipeline.Expression floor(com.google.firebase.firestore.pipeline.Expression numericExpr); method public com.google.firebase.firestore.pipeline.Expression floor(String numericField); + method public com.google.firebase.firestore.pipeline.Expression getField(com.google.firebase.firestore.pipeline.Expression expression, String key); method public com.google.firebase.firestore.pipeline.BooleanExpression greaterThan(com.google.firebase.firestore.pipeline.Expression left, com.google.firebase.firestore.pipeline.Expression right); method public com.google.firebase.firestore.pipeline.BooleanExpression greaterThan(com.google.firebase.firestore.pipeline.Expression left, Object right); method public com.google.firebase.firestore.pipeline.BooleanExpression greaterThan(String fieldName, com.google.firebase.firestore.pipeline.Expression expression); @@ -1537,6 +1551,7 @@ package com.google.firebase.firestore.pipeline { method public com.google.firebase.firestore.pipeline.Expression unixMillisToTimestamp(String fieldName); method public com.google.firebase.firestore.pipeline.Expression unixSecondsToTimestamp(com.google.firebase.firestore.pipeline.Expression expr); method public com.google.firebase.firestore.pipeline.Expression unixSecondsToTimestamp(String fieldName); + method public com.google.firebase.firestore.pipeline.Expression variable(String name); method public com.google.firebase.firestore.pipeline.Expression vector(com.google.firebase.firestore.VectorValue vector); method public com.google.firebase.firestore.pipeline.Expression vector(double[] vector); method public com.google.firebase.firestore.pipeline.Expression vectorLength(com.google.firebase.firestore.pipeline.Expression vectorExpression); @@ -1551,14 +1566,14 @@ package com.google.firebase.firestore.pipeline { public static final class Field.Companion { } - public final class FindNearestOptions extends com.google.firebase.firestore.pipeline.AbstractOptions { + @com.google.common.annotations.Beta public final class FindNearestOptions extends com.google.firebase.firestore.pipeline.AbstractOptions { ctor public FindNearestOptions(); method public com.google.firebase.firestore.pipeline.FindNearestOptions withDistanceField(com.google.firebase.firestore.pipeline.Field distanceField); method public com.google.firebase.firestore.pipeline.FindNearestOptions? withDistanceField(String? distanceField); method public com.google.firebase.firestore.pipeline.FindNearestOptions withLimit(long limit); } - public final class FindNearestStage extends com.google.firebase.firestore.pipeline.Stage { + @com.google.common.annotations.Beta public final class FindNearestStage extends com.google.firebase.firestore.pipeline.Stage { field public static final com.google.firebase.firestore.pipeline.FindNearestStage.Companion Companion; } @@ -1612,7 +1627,7 @@ package com.google.firebase.firestore.pipeline { public static final class RawOptions.Companion { } - public final class RawStage extends com.google.firebase.firestore.pipeline.Stage { + @com.google.common.annotations.Beta public final class RawStage extends com.google.firebase.firestore.pipeline.Stage { method public static com.google.firebase.firestore.pipeline.RawStage ofName(String name); method public com.google.firebase.firestore.pipeline.RawStage withArguments(java.lang.Object... arguments); field public static final com.google.firebase.firestore.pipeline.RawStage.Companion Companion; @@ -1622,7 +1637,7 @@ package com.google.firebase.firestore.pipeline { method public com.google.firebase.firestore.pipeline.RawStage ofName(String name); } - public final class SampleStage extends com.google.firebase.firestore.pipeline.Stage { + @com.google.common.annotations.Beta public final class SampleStage extends com.google.firebase.firestore.pipeline.Stage { method public static com.google.firebase.firestore.pipeline.SampleStage withDocLimit(int results); method public static com.google.firebase.firestore.pipeline.SampleStage withPercentage(double percentage); field public static final com.google.firebase.firestore.pipeline.SampleStage.Companion Companion; @@ -1648,7 +1663,7 @@ package com.google.firebase.firestore.pipeline { ctor public Selectable(); } - public abstract sealed class Stage> { + @com.google.common.annotations.Beta public abstract sealed class Stage> { method public final T withOption(String key, boolean value); method public final T withOption(String key, com.google.firebase.firestore.pipeline.Field value); method public final T withOption(String key, double value); @@ -1657,12 +1672,15 @@ package com.google.firebase.firestore.pipeline { method public final T withOption(String key, long value); } - public final class UnnestOptions extends com.google.firebase.firestore.pipeline.AbstractOptions { + public final class SubcollectionSource extends com.google.firebase.firestore.pipeline.Stage { + } + + @com.google.common.annotations.Beta public final class UnnestOptions extends com.google.firebase.firestore.pipeline.AbstractOptions { ctor public UnnestOptions(); method public com.google.firebase.firestore.pipeline.UnnestOptions withIndexField(String indexField); } - public final class UnnestStage extends com.google.firebase.firestore.pipeline.Stage { + @com.google.common.annotations.Beta public final class UnnestStage extends com.google.firebase.firestore.pipeline.Stage { method public static com.google.firebase.firestore.pipeline.UnnestStage withField(com.google.firebase.firestore.pipeline.Selectable arrayWithAlias); method public static com.google.firebase.firestore.pipeline.UnnestStage withField(String arrayField, String alias); method public com.google.firebase.firestore.pipeline.UnnestStage withIndexField(String indexField); diff --git a/firebase-firestore/src/test/java/com/google/firebase/firestore/pipeline/SubqueryPipelineTests.kt b/firebase-firestore/src/test/java/com/google/firebase/firestore/pipeline/SubqueryPipelineTests.kt index e0b341bf2c8..48c21166768 100644 --- a/firebase-firestore/src/test/java/com/google/firebase/firestore/pipeline/SubqueryPipelineTests.kt +++ b/firebase-firestore/src/test/java/com/google/firebase/firestore/pipeline/SubqueryPipelineTests.kt @@ -1,3 +1,17 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + package com.google.firebase.firestore.pipeline import com.google.common.truth.Truth.assertThat From a1bb90655f59b0919b39ec1e2f45fa1674b0f053 Mon Sep 17 00:00:00 2001 From: cherylEnkidu Date: Fri, 20 Feb 2026 17:33:12 -0500 Subject: [PATCH 23/53] Add APIs for getField() --- firebase-firestore/api.txt | 8 +++ .../firestore/pipeline/expressions.kt | 51 ++++++++++++++++++- 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/firebase-firestore/api.txt b/firebase-firestore/api.txt index d597d0cd099..b8b5cb9421c 100644 --- a/firebase-firestore/api.txt +++ b/firebase-firestore/api.txt @@ -979,7 +979,12 @@ package com.google.firebase.firestore.pipeline { method public final com.google.firebase.firestore.pipeline.Expression floor(); method public static final com.google.firebase.firestore.pipeline.Expression floor(com.google.firebase.firestore.pipeline.Expression numericExpr); method public static final com.google.firebase.firestore.pipeline.Expression floor(String numericField); + method public final com.google.firebase.firestore.pipeline.Expression getField(com.google.firebase.firestore.pipeline.Expression keyExpression); + method public static final com.google.firebase.firestore.pipeline.Expression getField(com.google.firebase.firestore.pipeline.Expression expression, com.google.firebase.firestore.pipeline.Expression keyExpression); method public static final com.google.firebase.firestore.pipeline.Expression getField(com.google.firebase.firestore.pipeline.Expression expression, String key); + method public final com.google.firebase.firestore.pipeline.Expression getField(String key); + method public static final com.google.firebase.firestore.pipeline.Expression getField(String fieldName, com.google.firebase.firestore.pipeline.Expression keyExpression); + method public static final com.google.firebase.firestore.pipeline.Expression getField(String fieldName, String key); method public final com.google.firebase.firestore.pipeline.BooleanExpression greaterThan(com.google.firebase.firestore.pipeline.Expression other); method public static final com.google.firebase.firestore.pipeline.BooleanExpression greaterThan(com.google.firebase.firestore.pipeline.Expression left, com.google.firebase.firestore.pipeline.Expression right); method public static final com.google.firebase.firestore.pipeline.BooleanExpression greaterThan(com.google.firebase.firestore.pipeline.Expression left, Object right); @@ -1377,7 +1382,10 @@ package com.google.firebase.firestore.pipeline { method public com.google.firebase.firestore.pipeline.Field field(String name); method public com.google.firebase.firestore.pipeline.Expression floor(com.google.firebase.firestore.pipeline.Expression numericExpr); method public com.google.firebase.firestore.pipeline.Expression floor(String numericField); + method public com.google.firebase.firestore.pipeline.Expression getField(com.google.firebase.firestore.pipeline.Expression expression, com.google.firebase.firestore.pipeline.Expression keyExpression); method public com.google.firebase.firestore.pipeline.Expression getField(com.google.firebase.firestore.pipeline.Expression expression, String key); + method public com.google.firebase.firestore.pipeline.Expression getField(String fieldName, com.google.firebase.firestore.pipeline.Expression keyExpression); + method public com.google.firebase.firestore.pipeline.Expression getField(String fieldName, String key); method public com.google.firebase.firestore.pipeline.BooleanExpression greaterThan(com.google.firebase.firestore.pipeline.Expression left, com.google.firebase.firestore.pipeline.Expression right); method public com.google.firebase.firestore.pipeline.BooleanExpression greaterThan(com.google.firebase.firestore.pipeline.Expression left, Object right); method public com.google.firebase.firestore.pipeline.BooleanExpression greaterThan(String fieldName, com.google.firebase.firestore.pipeline.Expression expression); diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/expressions.kt b/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/expressions.kt index 7a2df6af592..533f69b4ec4 100644 --- a/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/expressions.kt +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/expressions.kt @@ -2972,7 +2972,7 @@ abstract class Expression internal constructor() { map(elements.flatMap { listOf(constant(it.key), toExprOrConstant(it.value)) }.toTypedArray()) /** - * Accesses a field/property of the expression (useful when the expression evaluates to a Map or + * Accesses a field/property of the expression (When the expression evaluates to a Map or * Document). * * @param key The key of the field to access. @@ -2982,6 +2982,39 @@ abstract class Expression internal constructor() { fun getField(expression: Expression, key: String): Expression = FunctionExpression("field", notImplemented, expression, key) + /** + * Accesses a field/property of a document field using the provided [key]. + * + * @param fieldName The field name of the map or document field. + * @param key The key of the field to access. + * @return An [Expression] representing the value of the field. + */ + @JvmStatic + fun getField(fieldName: String, key: String): Expression = + FunctionExpression("field", notImplemented, fieldName, key) + + /** + * Accesses a field/property of the expression using the provided [keyExpression]. + * + * @param expression The expression evaluating to a Map or Document. + * @param keyExpression The expression evaluating to the key. + * @return A new [Expression] representing the value of the field. + */ + @JvmStatic + fun getField(expression: Expression, keyExpression: Expression): Expression = + FunctionExpression("field", notImplemented, expression, keyExpression) + + /** + * Accesses a field/property of a document field using the provided [keyExpression]. + * + * @param fieldName The field name of the map or document field. + * @param keyExpression The expression evaluating to the key. + * @return A new [Expression] representing the value of the field. + */ + @JvmStatic + fun getField(fieldName: String, keyExpression: Expression): Expression = + FunctionExpression("field", notImplemented, fieldName, keyExpression) + /** * Accesses a value from a map (object) field using the provided [key]. * @@ -6401,6 +6434,22 @@ abstract class Expression internal constructor() { fun mapMerge(mapExpr: Expression, vararg otherMaps: Expression) = Companion.mapMerge(this, mapExpr, *otherMaps) + /** + * Retrieves the value of a specific field from the document evaluated by this expression. + * + * @param key The string key to access. + * @return A new [Expression] representing the field value. + */ + fun getField(key: String): Expression = Companion.getField(this, key) + + /** + * Retrieves the value of a specific field from the document evaluated by this expression. + * + * @param keyExpression The expression evaluating to the key to access. + * @return A new [Expression] representing the field value. + */ + fun getField(keyExpression: Expression): Expression = Companion.getField(this, keyExpression) + /** * Creates an expression that removes a key from this map expression. * From 7938f8d0ecb754cfed94612d927dba95f133e5dd Mon Sep 17 00:00:00 2001 From: cherylEnkidu Date: Tue, 24 Feb 2026 12:02:16 -0500 Subject: [PATCH 24/53] change the documentation --- .../src/main/java/com/google/firebase/firestore/Pipeline.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/Pipeline.kt b/firebase-firestore/src/main/java/com/google/firebase/firestore/Pipeline.kt index 62de2085e19..75251eb2c89 100644 --- a/firebase-firestore/src/main/java/com/google/firebase/firestore/Pipeline.kt +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/Pipeline.kt @@ -909,8 +909,8 @@ internal constructor( fun unnest(unnestStage: UnnestStage): Pipeline = append(unnestStage) /** - * Defines one or more variables in the pipeline's scope, allowing them to be used in subsequent - * stages. + * Defines one or more variables in the pipeline's scope. `define` is used to bind a value to a + * variable for internal reuse within the pipeline body (accessed via the `variable()` function). * * This stage is useful for declaring reusable values or intermediate calculations that can be * referenced multiple times in later parts of the pipeline, improving readability and From ddc73a0048eba2e384fef31a9be8f57e66f5e305 Mon Sep 17 00:00:00 2001 From: cherylEnkidu Date: Wed, 25 Feb 2026 12:59:27 -0500 Subject: [PATCH 25/53] Add DefineStage API --- firebase-firestore/api.txt | 10 ++ .../com/google/firebase/firestore/Pipeline.kt | 126 +++++++++++++++++- .../firebase/firestore/pipeline/stage.kt | 18 ++- 3 files changed, 149 insertions(+), 5 deletions(-) diff --git a/firebase-firestore/api.txt b/firebase-firestore/api.txt index b8b5cb9421c..16a0419b7dc 100644 --- a/firebase-firestore/api.txt +++ b/firebase-firestore/api.txt @@ -425,6 +425,7 @@ package com.google.firebase.firestore { method public com.google.firebase.firestore.Pipeline aggregate(com.google.firebase.firestore.pipeline.AggregateStage aggregateStage, com.google.firebase.firestore.pipeline.AggregateOptions options); method public com.google.firebase.firestore.Pipeline aggregate(com.google.firebase.firestore.pipeline.AliasedAggregate accumulator, com.google.firebase.firestore.pipeline.AliasedAggregate... additionalAccumulators); method public com.google.firebase.firestore.Pipeline define(com.google.firebase.firestore.pipeline.AliasedExpression aliasedExpression, com.google.firebase.firestore.pipeline.AliasedExpression... additionalExpressions); + method public com.google.firebase.firestore.Pipeline define(com.google.firebase.firestore.pipeline.DefineStage stage); method public com.google.firebase.firestore.Pipeline distinct(com.google.firebase.firestore.pipeline.Selectable group, java.lang.Object... additionalGroups); method public com.google.firebase.firestore.Pipeline distinct(String groupField, java.lang.Object... additionalGroups); method public com.google.android.gms.tasks.Task execute(); @@ -789,6 +790,15 @@ package com.google.firebase.firestore.pipeline { method public com.google.firebase.firestore.pipeline.CollectionSourceOptions withHints(com.google.firebase.firestore.pipeline.CollectionHints hints); } + @com.google.common.annotations.Beta public final class DefineStage extends com.google.firebase.firestore.pipeline.Stage { + method public static com.google.firebase.firestore.pipeline.DefineStage withVariables(com.google.firebase.firestore.pipeline.AliasedExpression aliasedExpression, com.google.firebase.firestore.pipeline.AliasedExpression... additionalExpressions); + field public static final com.google.firebase.firestore.pipeline.DefineStage.Companion Companion; + } + + public static final class DefineStage.Companion { + method public com.google.firebase.firestore.pipeline.DefineStage withVariables(com.google.firebase.firestore.pipeline.AliasedExpression aliasedExpression, com.google.firebase.firestore.pipeline.AliasedExpression... additionalExpressions); + } + @com.google.common.annotations.Beta public abstract class Expression { method public final com.google.firebase.firestore.pipeline.Expression abs(); method public static final com.google.firebase.firestore.pipeline.Expression abs(com.google.firebase.firestore.pipeline.Expression numericExpr); diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/Pipeline.kt b/firebase-firestore/src/main/java/com/google/firebase/firestore/Pipeline.kt index 75251eb2c89..21c1c462a27 100644 --- a/firebase-firestore/src/main/java/com/google/firebase/firestore/Pipeline.kt +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/Pipeline.kt @@ -942,6 +942,38 @@ internal constructor( return append(DefineStage(arrayOf(aliasedExpression, *additionalExpressions))) } + /** + * Defines one or more variables in the pipeline's scope using a [DefineStage] object. + * + * This stage allows you to bind a value to a variable for internal reuse within the pipeline body + * (accessed via the `variable()` function). It is useful for declaring reusable values or + * intermediate calculations that can be referenced multiple times in later parts of the pipeline, + * improving readability and maintainability. + * + * You can specify: + * + * - **Variables:** One or more variables using [AliasedExpression] which pairs an expression with + * a name (alias). The expression can be a simple constant, a field reference, or a complex + * computation. + * + * Example: + * ``` + * firestore.pipeline().collection("products") + * .define( + * DefineStage.withVariables( + * multiply(field("price"), 0.9).as("discountedPrice"), + * add(field("stock"), 10).as("newStock") + * ) + * ) + * .where(lessThan(variable("discountedPrice"), 100)) + * .select(field("name"), variable("newStock")); + * ``` + * + * @param stage A [DefineStage] object that specifies the variables to define. + * @return A new [Pipeline] object with this stage appended to the stage list. + */ + fun define(stage: DefineStage): Pipeline = append(stage) + /** * Converts this pipeline to an expression that evaluates to an array of results. * @@ -952,10 +984,98 @@ internal constructor( } /** - * Converts this pipeline to an expression that evaluates to a scalar result. The pipeline must - * return exactly one document with one field, or be an aggregation. + * Converts this Pipeline into an expression that evaluates to a single scalar result. Used for + * 1:1 lookups or Aggregations when the subquery is expected to return a single value or object. + * + * **Runtime Validation:** The runtime will validate that the result set contains exactly one + * item. It throws a runtime error if the result has more than one item, and evaluates to `null` + * if the pipeline has zero results. + * + * **Result Unwrapping:** For simpler access, scalar subqueries producing a single field + * automatically unwrap that value to the top level, ignoring the inner alias. If the subquery + * returns multiple fields, they are preserved as a map. + * + * **Example 1: Single field unwrapping** + * ```kotlin + * // Calculate average rating for each restaurant using a subquery + * db.pipeline().collection("restaurants") + * .define(field("id").alias("rid")) + * .addFields( + * db.pipeline().collection("reviews") + * .where(field("restaurant_id").equal(variable("rid"))) + * // Inner aggregation returns a single document + * .aggregate(AggregateFunction.average("rating").alias("value")) + * // Convert Pipeline -> Scalar Expression (validates result is 1 item) + * .toScalarExpression() + * .alias("average_rating") + * ) + * ``` + * + * *The result set is unwrapped twice: from `"average_rating": [{ "value": 4.5 }]` to + * `"average_rating": { "value": 4.5 }`, and finally to `"average_rating": 4.5`.* + * + * ```json + * // Output Document: + * [ + * { + * "id": "123", + * "name": "The Burger Joint", + * "cuisine": "American", + * "average_rating": 4.5 + * }, + * { + * "id": "456", + * "name": "Sushi World", + * "cuisine": "Japanese", + * "average_rating": 4.8 + * } + * ] + * ``` + * + * **Example 2: Multiple fields (Map)** + * ```kotlin + * // For each restaurant, calculate review statistics (average rating AND total count) + * db.pipeline().collection("restaurants") + * .define(field("id").alias("rid")) + * .addFields( + * db.pipeline().collection("reviews") + * .where(field("restaurant_id").equal(variable("rid"))) + * .aggregate( + * AggregateFunction.average("rating").alias("avg_score"), + * AggregateFunction.countAll().alias("review_count") + * ) + * .toScalarExpression() + * .alias("stats") + * ) + * ``` + * + * *When the subquery produces multiple fields, they are wrapped in a map:* + * + * ```json + * // Output Document: + * [ + * { + * "id": "123", + * "name": "The Burger Joint", + * "cuisine": "American", + * "stats": { + * "avg_score": 4.0, + * "review_count": 3 + * } + * }, + * { + * "id": "456", + * "name": "Sushi World", + * "cuisine": "Japanese", + * "stats": { + * "avg_score": 4.8, + * "review_count": 120 + * } + * } + * ] + * ``` * - * @return An [Expression] that executes this pipeline and returns a single value. + * @return An [Expression] representing the execution of this pipeline. */ fun toScalarExpression(): Expression { return FunctionExpression("scalar", notImplemented, Expression.toExprOrConstant(this)) diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/stage.kt b/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/stage.kt index 1475949f928..b2fe0cfb21f 100644 --- a/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/stage.kt +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/stage.kt @@ -1338,11 +1338,24 @@ class UnnestOptions private constructor(options: InternalOptions) : } } -internal class DefineStage +@Beta +class DefineStage internal constructor( - private val aliasedExpressions: Array, + internal val aliasedExpressions: Array, options: InternalOptions = InternalOptions.EMPTY ) : Stage("let", options) { + companion object { + /** + * Creates a DefineStage with at least one aliased expression. + */ + @JvmStatic + fun withVariables( + aliasedExpression: AliasedExpression, + vararg additionalExpressions: AliasedExpression + ): DefineStage { + return DefineStage(arrayOf(aliasedExpression, *additionalExpressions)) + } + } override fun self(options: InternalOptions) = DefineStage(aliasedExpressions, options) @@ -1367,4 +1380,5 @@ internal constructor( result = 31 * result + options.hashCode() return result } + } From a382742d7ffc84a3376e728e89c239d68d56c809 Mon Sep 17 00:00:00 2001 From: cherylEnkidu Date: Wed, 25 Feb 2026 15:44:46 -0500 Subject: [PATCH 26/53] Add subcollection source option --- .../com/google/firebase/firestore/Pipeline.kt | 16 ++++++++++-- .../firebase/firestore/pipeline/stage.kt | 26 ++++++++++++------- 2 files changed, 31 insertions(+), 11 deletions(-) diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/Pipeline.kt b/firebase-firestore/src/main/java/com/google/firebase/firestore/Pipeline.kt index 21c1c462a27..5fd1b245a8d 100644 --- a/firebase-firestore/src/main/java/com/google/firebase/firestore/Pipeline.kt +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/Pipeline.kt @@ -1090,8 +1090,20 @@ internal constructor( * @return A new [Pipeline] scoped to the subcollection. */ @JvmStatic - fun subcollection(path: String): Pipeline { - return Pipeline(null, null, SubcollectionSource(path)) + internal fun subcollection(path: String): Pipeline { + return Pipeline(null, null, SubcollectionSource.of(path)) + } + + /** + * Creates a pipeline that processes the documents in the specified subcollection of the current + * document. + * + * @param source The subcollection that will be the source of this pipeline. + * @return A new [Pipeline] scoped to the subcollection. + */ + @JvmStatic + internal fun subcollection(source: SubcollectionSource): Pipeline { + return Pipeline(null, null, source) } } } diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/stage.kt b/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/stage.kt index b2fe0cfb21f..353936d87b8 100644 --- a/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/stage.kt +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/stage.kt @@ -407,9 +407,20 @@ internal constructor( documents.asSequence().map(::encodeValue) } -class SubcollectionSource -internal constructor(internal val path: String, options: InternalOptions = InternalOptions.EMPTY) : +internal class SubcollectionSource +private constructor(internal val path: String, options: InternalOptions = InternalOptions.EMPTY) : Stage("subcollection", options) { + companion object { + /** + * Creates a SubcollectionSource with the given path. + * + * @param path The path of the subcollection that will be the source of this pipeline. + */ + @JvmStatic + internal fun of(path: String): SubcollectionSource { + return SubcollectionSource(path) + } + } override fun self(options: InternalOptions) = SubcollectionSource(path, options) @@ -1345,15 +1356,13 @@ internal constructor( options: InternalOptions = InternalOptions.EMPTY ) : Stage("let", options) { companion object { - /** - * Creates a DefineStage with at least one aliased expression. - */ + /** Creates a DefineStage with at least one aliased expression. */ @JvmStatic fun withVariables( - aliasedExpression: AliasedExpression, - vararg additionalExpressions: AliasedExpression + aliasedExpression: AliasedExpression, + vararg additionalExpressions: AliasedExpression ): DefineStage { - return DefineStage(arrayOf(aliasedExpression, *additionalExpressions)) + return DefineStage(arrayOf(aliasedExpression, *additionalExpressions)) } } @@ -1380,5 +1389,4 @@ internal constructor( result = 31 * result + options.hashCode() return result } - } From ff3f826c2afc078efd7c6ba4916401e4c902f5e4 Mon Sep 17 00:00:00 2001 From: cherylEnkidu Date: Fri, 27 Feb 2026 16:16:09 -0500 Subject: [PATCH 27/53] Update documentation --- .../firestore/SubqueryIntegrationTest.kt | 1 - .../com/google/firebase/firestore/Pipeline.kt | 23 ++++++++++++++++++- .../firestore/pipeline/expressions.kt | 17 ++++++++++++++ 3 files changed, 39 insertions(+), 2 deletions(-) diff --git a/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/SubqueryIntegrationTest.kt b/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/SubqueryIntegrationTest.kt index f4bc03180a7..4a526d4c48f 100644 --- a/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/SubqueryIntegrationTest.kt +++ b/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/SubqueryIntegrationTest.kt @@ -56,7 +56,6 @@ class SubqueryIntegrationTest { IntegrationTestUtil.tearDown() } - @org.junit.Ignore @Test fun testSubquery() { val reviewCollectionId = autoId() diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/Pipeline.kt b/firebase-firestore/src/main/java/com/google/firebase/firestore/Pipeline.kt index 5fd1b245a8d..9c618a3266b 100644 --- a/firebase-firestore/src/main/java/com/google/firebase/firestore/Pipeline.kt +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/Pipeline.kt @@ -1089,8 +1089,29 @@ internal constructor( * @param path The relative path to the subcollection. * @return A new [Pipeline] scoped to the subcollection. */ + /** + * Initializes a pipeline scoped to a subcollection. + * + * This method allows you to start a new pipeline that operates on a subcollection of the + * current document. It is intended to be used as a subquery. + * + * **Note:** A pipeline created with `subcollection` cannot be executed directly using + * [Pipeline.snapshot]. It must be used within a parent pipeline. + * + * Example: + * ``` + * firestore.pipeline().collection("books") + * .addFields( + * Pipeline.subcollection("reviews") + * .aggregate(AggregateFunction.average("rating").as("avg_rating")) + * .toScalarExpression().as("average_rating")); + * ``` + * + * @param path The path of the subcollection. + * @return A new [Pipeline] instance scoped to the subcollection. + */ @JvmStatic - internal fun subcollection(path: String): Pipeline { + fun subcollection(path: String): Pipeline { return Pipeline(null, null, SubcollectionSource.of(path)) } diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/expressions.kt b/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/expressions.kt index 533f69b4ec4..ed7f2043b6a 100644 --- a/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/expressions.kt +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/expressions.kt @@ -5332,6 +5332,14 @@ abstract class Expression internal constructor() { /** * Creates an expression that retrieves the value of a variable bound via [Pipeline.define]. * + * Example: + * ``` + * // Define a variable "discountedPrice" and use it in a filter + * firestore.pipeline().collection("products") + * .define(Constant(100).as("threshold")) + * .where(lessThan(variable("discountedPrice"), variable("threshold"))); + * ``` + * * @param name The name of the variable to retrieve. * @return An [Expression] representing the variable's value. */ @@ -5340,6 +5348,15 @@ abstract class Expression internal constructor() { /** * Creates an expression that represents the current document being processed. * + * Example: + * ``` + * // Define the current document as a variable "doc" + * firestore.pipeline().collection("books") + * .define(currentDocument().as("doc")) + * // Access a field from the defined document variable + * .select(variable("doc").getField("title")); + * ``` + * * @return An [Expression] representing the current document. */ @JvmStatic From e36107234c6c1e4bfc799ad33bd8d1def9a90d17 Mon Sep 17 00:00:00 2001 From: cherylEnkidu Date: Fri, 27 Feb 2026 16:52:12 -0500 Subject: [PATCH 28/53] Add tests --- .../firestore/SubqueryIntegrationTest.kt | 1113 ++++++++++++++++- 1 file changed, 1072 insertions(+), 41 deletions(-) diff --git a/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/SubqueryIntegrationTest.kt b/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/SubqueryIntegrationTest.kt index 4a526d4c48f..634f97402b4 100644 --- a/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/SubqueryIntegrationTest.kt +++ b/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/SubqueryIntegrationTest.kt @@ -26,29 +26,156 @@ import com.google.firebase.firestore.testutil.IntegrationTestUtil.waitFor import com.google.firebase.firestore.util.Util.autoId import org.junit.After import org.junit.Before +import org.junit.Ignore import org.junit.Test import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class SubqueryIntegrationTest { private lateinit var db: FirebaseFirestore + private lateinit var collection: CollectionReference + + private val bookDocs = + mapOf( + "book1" to + mapOf( + "title" to "The Hitchhiker's Guide to the Galaxy", + "author" to "Douglas Adams", + "genre" to "Science Fiction", + "published" to 1979, + "rating" to 4.2, + "tags" to listOf("comedy", "space", "adventure"), + "awards" to mapOf("hugo" to true, "nebula" to false), + "embedding" to + FieldValue.vector(doubleArrayOf(10.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0)) + ), + "book2" to + mapOf( + "title" to "Pride and Prejudice", + "author" to "Jane Austen", + "genre" to "Romance", + "published" to 1813, + "rating" to 4.5, + "tags" to listOf("classic", "social commentary", "love"), + "awards" to mapOf("none" to true), + "embedding" to + FieldValue.vector(doubleArrayOf(1.0, 10.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0)) + ), + "book3" to + mapOf( + "title" to "One Hundred Years of Solitude", + "author" to "Gabriel García Márquez", + "genre" to "Magical Realism", + "published" to 1967, + "rating" to 4.3, + "tags" to listOf("family", "history", "fantasy"), + "awards" to mapOf("nobel" to true, "nebula" to false), + "embedding" to + FieldValue.vector(doubleArrayOf(1.0, 1.0, 10.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0)) + ), + "book4" to + mapOf( + "title" to "The Lord of the Rings", + "author" to "J.R.R. Tolkien", + "genre" to "Fantasy", + "published" to 1954, + "rating" to 4.7, + "tags" to listOf("adventure", "magic", "epic"), + "awards" to mapOf("hugo" to false, "nebula" to false), + "cost" to Double.NaN, + "embedding" to + FieldValue.vector(doubleArrayOf(1.0, 1.0, 1.0, 10.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0)) + ), + "book5" to + mapOf( + "title" to "The Handmaid's Tale", + "author" to "Margaret Atwood", + "genre" to "Dystopian", + "published" to 1985, + "rating" to 4.1, + "tags" to listOf("feminism", "totalitarianism", "resistance"), + "awards" to mapOf("arthur c. clarke" to true, "booker prize" to false), + "embedding" to + FieldValue.vector(doubleArrayOf(1.0, 1.0, 1.0, 1.0, 10.0, 1.0, 1.0, 1.0, 1.0, 1.0)) + ), + "book6" to + mapOf( + "title" to "Crime and Punishment", + "author" to "Fyodor Dostoevsky", + "genre" to "Psychological Thriller", + "published" to 1866, + "rating" to 4.3, + "tags" to listOf("philosophy", "crime", "redemption"), + "awards" to mapOf("none" to true), + "embedding" to + FieldValue.vector(doubleArrayOf(1.0, 1.0, 1.0, 1.0, 1.0, 10.0, 1.0, 1.0, 1.0, 1.0)) + ), + "book7" to + mapOf( + "title" to "To Kill a Mockingbird", + "author" to "Harper Lee", + "genre" to "Southern Gothic", + "published" to 1960, + "rating" to 4.2, + "tags" to listOf("racism", "injustice", "coming-of-age"), + "awards" to mapOf("pulitzer" to true), + "embedding" to + FieldValue.vector(doubleArrayOf(1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 10.0, 1.0, 1.0, 1.0)) + ), + "book8" to + mapOf( + "title" to "1984", + "author" to "George Orwell", + "genre" to "Dystopian", + "published" to 1949, + "rating" to 4.2, + "tags" to listOf("surveillance", "totalitarianism", "propaganda"), + "awards" to mapOf("prometheus" to true), + "embedding" to + FieldValue.vector(doubleArrayOf(1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 10.0, 1.0, 1.0)) + ), + "book9" to + mapOf( + "title" to "The Great Gatsby", + "author" to "F. Scott Fitzgerald", + "genre" to "Modernist", + "published" to 1925, + "rating" to 4.0, + "tags" to listOf("wealth", "american dream", "love"), + "awards" to mapOf("none" to true), + "embedding" to + FieldValue.vector(doubleArrayOf(1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 10.0, 1.0)) + ), + "book10" to + mapOf( + "title" to "Dune", + "author" to "Frank Herbert", + "genre" to "Science Fiction", + "published" to 1965, + "rating" to 4.6, + "tags" to listOf("politics", "desert", "ecology"), + "awards" to mapOf("hugo" to true, "nebula" to true), + "embedding" to + FieldValue.vector(doubleArrayOf(1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 10.0)) + ), + "book11" to + mapOf( + "title" to "Timestamp Book", + "author" to "Timestamp Author", + "timestamp" to java.util.Date() + ) + ) @Before fun setUp() { - // org.junit.Assume.assumeTrue( - // "Skip SubqueryIntegrationTest on prod", - // IntegrationTestUtil.isRunningAgainstEmulator() - // ) org.junit.Assume.assumeTrue( "Skip SubqueryIntegrationTest on standard backend", IntegrationTestUtil.getBackendEdition() == IntegrationTestUtil.BackendEdition.ENTERPRISE ) - // Using testFirestore() ensures we get a uniquely configured instance if needed, - // but typically we want a clean DB reference. - // IntegrationTestUtil.testFirestore() is standard. - val collRef = IntegrationTestUtil.testCollection() - db = collRef.firestore + // Using IntegrationTestUtil.testCollectionWithDocs to populate data + collection = IntegrationTestUtil.testCollectionWithDocs(bookDocs) + db = collection.firestore } @After @@ -56,49 +183,953 @@ class SubqueryIntegrationTest { IntegrationTestUtil.tearDown() } + // Batch 1: Scalar Subqueries + @Test - fun testSubquery() { - val reviewCollectionId = autoId() - val reviewerCollectionId = autoId() + fun testZeroResultScalarReturnsNull() { + val testDocs = mapOf("book1" to mapOf("title" to "A Book Title")) + for ((key, value) in testDocs) { + waitFor(collection.document(key).set(value)) + } + + val emptyScalar = + db + .pipeline() + .collection(collection.document("book1").collection("reviews").path) + .where(equal("reviewer", "Alice")) + .select(com.google.firebase.firestore.pipeline.Expression.currentDocument().alias("data")) - // Setup reviewers - val reviewersCollection = db.collection(reviewerCollectionId) - val r1 = reviewersCollection.document("r1") - waitFor(r1.set(mapOf("name" to "reviewer1"))) + val results = + waitFor( + db + .pipeline() + .collection(collection.path) + .select(emptyScalar.toScalarExpression().alias("first_review_data")) + .limit(1) + .execute() + ) + } - // Setup reviews - // Using collectionGroup requires consistent collection ID across hierarchy or just any - // collection with that ID. - // We'll create a top-level collection with the random ID for simplicity. - val reviewsRef = db.collection(reviewCollectionId) + @Test + fun testScalarSubqueryZeroResults() { + val reviewsCollName = "reviews_zero_" + autoId() - // Store author as a DocumentReference to match __name__ which is a Reference. - waitFor(reviewsRef.document("run1_1").set(mapOf("author" to r1, "rating" to 5))) - waitFor(reviewsRef.document("run1_2").set(mapOf("author" to r1, "rating" to 3))) + // No reviews for "1984" - // Construct subquery - // Find reviews where author matches the variable 'author' - val subquery = + val reviewsSub = db .pipeline() - .collectionGroup(reviewCollectionId) - .where(equal("author", variable("author"))) - .aggregate(field("rating").average().alias("avg_rating")) + .collection(reviewsCollName) + .where( + equal( + "bookTitle", + com.google.firebase.firestore.pipeline.Expression.variable("book_title") + ) + ) + .aggregate( + com.google.firebase.firestore.pipeline.AggregateFunction.average("rating").alias("avg") + ) - // Construct main pipeline - val pipeline = + val results = + waitFor( + db + .pipeline() + .collection(collection.path) + .where(equal("title", "1984")) // "1984" exists in the main collection from setup + .define(field("title").alias("book_title")) + .addFields(reviewsSub.toScalarExpression().alias("average_rating")) + .select("title", "average_rating") + .execute() + ) + + assertThat(results.map { it.getData() }) + .containsExactly(mapOf("title" to "1984", "average_rating" to null)) + } + + @Test + fun testScalarSubquerySingleAggregationUnwrapping() { + val reviewsCollName = "reviews_agg_single_" + autoId() + + val reviewsCollection = db.collection(reviewsCollName) + waitFor(reviewsCollection.document("r1").set(mapOf("bookTitle" to "1984", "rating" to 4))) + waitFor(reviewsCollection.document("r2").set(mapOf("bookTitle" to "1984", "rating" to 5))) + + val reviewsSub = db .pipeline() - .collection(reviewerCollectionId) - .define(field("__name__").alias("author")) - .addFields(subquery.toScalarExpression().alias("avg_review")) + .collection(reviewsCollName) + .where( + equal( + "bookTitle", + com.google.firebase.firestore.pipeline.Expression.variable("book_title") + ) + ) + .aggregate( + com.google.firebase.firestore.pipeline.AggregateFunction.average("rating").alias("val") + ) + + val results = + waitFor( + db + .pipeline() + .collection(collection.path) + .where(equal("title", "1984")) + .define(field("title").alias("book_title")) + .addFields(reviewsSub.toScalarExpression().alias("average_rating")) + .select("title", "average_rating") + .execute() + ) + + assertThat(results.map { it.getData() }) + .containsExactly(mapOf("title" to "1984", "average_rating" to 4.5)) + } + + @Test + fun testScalarSubqueryMultipleAggregationsMapWrapping() { + val reviewsCollName = "reviews_agg_multi_" + autoId() + + val reviewsCollection = db.collection(reviewsCollName) + waitFor(reviewsCollection.document("r1").set(mapOf("bookTitle" to "1984", "rating" to 4))) + waitFor(reviewsCollection.document("r2").set(mapOf("bookTitle" to "1984", "rating" to 5))) + + val reviewsSub = + db + .pipeline() + .collection(reviewsCollName) + .where( + equal( + "bookTitle", + com.google.firebase.firestore.pipeline.Expression.variable("book_title") + ) + ) + .aggregate( + com.google.firebase.firestore.pipeline.AggregateFunction.average("rating").alias("avg"), + com.google.firebase.firestore.pipeline.AggregateFunction.countAll().alias("count") + ) + + val results = + waitFor( + db + .pipeline() + .collection(collection.path) + .where(equal("title", "1984")) + .define(field("title").alias("book_title")) + .addFields(reviewsSub.toScalarExpression().alias("stats")) + .select("title", "stats") + .execute() + ) + + assertThat(results.map { it.getData() }) + .containsExactly(mapOf("title" to "1984", "stats" to mapOf("avg" to 4.5, "count" to 2L))) + } + + @Test + fun testScalarSubqueryMultipleResultsRuntimeError() { + val reviewsCollName = "reviews_multiple_" + autoId() + + val reviewsCollection = db.collection(reviewsCollName) + waitFor(reviewsCollection.document("r1").set(mapOf("bookTitle" to "1984", "rating" to 4))) + waitFor(reviewsCollection.document("r2").set(mapOf("bookTitle" to "1984", "rating" to 5))) + + // This subquery will return 2 documents, which is invalid for toScalarExpression() + val reviewsSub = + db + .pipeline() + .collection(reviewsCollName) + .where( + equal( + "bookTitle", + com.google.firebase.firestore.pipeline.Expression.variable("book_title") + ) + ) + + val exception = + org.junit.Assert.assertThrows(RuntimeException::class.java) { + waitFor( + db + .pipeline() + .collection(collection.path) + .where(equal("title", "1984")) + .define(field("title").alias("book_title")) + .addFields(reviewsSub.toScalarExpression().alias("review_data")) + .execute() + ) + } + + // Assert that it's an API error from the backend complaining about multiple results + assertThat(exception.cause?.message).contains("Subpipeline returned multiple results.") + } + + // Batch 2: Array Subqueries & Joins + + @Test + fun testArraySubqueryJoinAndEmptyResult() { + val reviewsCollName = "book_reviews_" + autoId() + val reviewsDocs = + mapOf( + "r1" to mapOf("bookTitle" to "The Hitchhiker's Guide to the Galaxy", "reviewer" to "Alice"), + "r2" to mapOf("bookTitle" to "The Hitchhiker's Guide to the Galaxy", "reviewer" to "Bob") + ) + + val reviewsCollection = db.collection(reviewsCollName) + for ((key, value) in reviewsDocs) { + waitFor(reviewsCollection.document(key).set(value)) + } + + val reviewsSub = + db + .pipeline() + .collection(reviewsCollName) + .where( + equal( + "bookTitle", + com.google.firebase.firestore.pipeline.Expression.variable("book_title") + ) + ) + .select(field("reviewer").alias("reviewer")) + .sort(field("reviewer").ascending()) + + val results = + waitFor( + db + .pipeline() + .collection(collection.path) + .where( + com.google.firebase.firestore.pipeline.Expression.or( + equal("title", "The Hitchhiker's Guide to the Galaxy"), + equal("title", "Pride and Prejudice") + ) + ) + .define(field("title").alias("book_title")) + .addFields(reviewsSub.toArrayExpression().alias("reviews_data")) + .select("title", "reviews_data") + .sort(field("title").descending()) + .execute() + ) + + assertThat(results.map { it.getData() }) + .containsExactly( + mapOf( + "title" to "The Hitchhiker's Guide to the Galaxy", + "reviews_data" to listOf("Alice", "Bob") + ), + mapOf("title" to "Pride and Prejudice", "reviews_data" to emptyList()) + ) + .inOrder() + } + + @Test + fun testMultipleArraySubqueriesOnBooks() { + val reviewsCollName = "reviews_multi_" + autoId() + val authorsCollName = "authors_multi_" + autoId() + + waitFor( + db.collection(reviewsCollName).document("r1").set(mapOf("bookTitle" to "1984", "rating" to 5)) + ) + + waitFor( + db + .collection(authorsCollName) + .document("a1") + .set(mapOf("authorName" to "George Orwell", "nationality" to "British")) + ) + + val reviewsSub = + db + .pipeline() + .collection(reviewsCollName) + .where( + equal( + "bookTitle", + com.google.firebase.firestore.pipeline.Expression.variable("book_title") + ) + ) + .select(field("rating").alias("rating")) + + val authorsSub = + db + .pipeline() + .collection(authorsCollName) + .where( + equal( + "authorName", + com.google.firebase.firestore.pipeline.Expression.variable("author_name") + ) + ) + .select(field("nationality").alias("nationality")) + + val results = + waitFor( + db + .pipeline() + .collection(collection.path) + .where(equal("title", "1984")) + .define(field("title").alias("book_title"), field("author").alias("author_name")) + .addFields( + reviewsSub.toArrayExpression().alias("reviews_data"), + authorsSub.toArrayExpression().alias("authors_data") + ) + .select("title", "reviews_data", "authors_data") + .execute() + ) + + assertThat(results.map { it.getData() }) + .containsExactly( + mapOf("title" to "1984", "reviews_data" to listOf(5L), "authors_data" to listOf("British")) + ) + } + + @Test + fun testArraySubqueryJoinMultipleFieldsPreservesMap() { + val reviewsCollName = "reviews_map_" + autoId() + val reviewsCollection = db.collection(reviewsCollName) + + waitFor( + reviewsCollection + .document("r1") + .set(mapOf("bookTitle" to "1984", "reviewer" to "Alice", "rating" to 5)) + ) + waitFor( + reviewsCollection + .document("r2") + .set(mapOf("bookTitle" to "1984", "reviewer" to "Bob", "rating" to 4)) + ) + + val reviewsSub = + db + .pipeline() + .collection(reviewsCollName) + .where( + equal( + "bookTitle", + com.google.firebase.firestore.pipeline.Expression.variable("book_title") + ) + ) + .select(field("reviewer").alias("reviewer"), field("rating").alias("rating")) + .sort(field("reviewer").ascending()) + + val results = + waitFor( + db + .pipeline() + .collection(collection.path) + .where(equal("title", "1984")) + .define(field("title").alias("book_title")) + .addFields(reviewsSub.toArrayExpression().alias("reviews_data")) + .select("title", "reviews_data") + .execute() + ) + + assertThat(results.map { it.getData() }) + .containsExactly( + mapOf( + "title" to "1984", + "reviews_data" to + listOf( + mapOf("reviewer" to "Alice", "rating" to 5L), + mapOf("reviewer" to "Bob", "rating" to 4L) + ) + ) + ) + } + + @Test + fun testMixedScalarAndArraySubqueries() { + val reviewsCollName = "reviews_mixed_" + autoId() + val reviewsCollection = db.collection(reviewsCollName) + + // Set up some reviews + waitFor( + reviewsCollection + .document("r1") + .set(mapOf("bookTitle" to "1984", "reviewer" to "Alice", "rating" to 4)) + ) + waitFor( + reviewsCollection + .document("r2") + .set(mapOf("bookTitle" to "1984", "reviewer" to "Bob", "rating" to 5)) + ) + + // Array subquery for all reviewers + val arraySub = + db + .pipeline() + .collection(reviewsCollName) + .where( + equal( + "bookTitle", + com.google.firebase.firestore.pipeline.Expression.variable("book_title") + ) + ) + .select(field("reviewer").alias("reviewer")) + .sort(field("reviewer").ascending()) + + // Scalar subquery for the average rating + val scalarSub = + db + .pipeline() + .collection(reviewsCollName) + .where( + equal( + "bookTitle", + com.google.firebase.firestore.pipeline.Expression.variable("book_title") + ) + ) + .aggregate( + com.google.firebase.firestore.pipeline.AggregateFunction.average("rating").alias("val") + ) + + val results = + waitFor( + db + .pipeline() + .collection(collection.path) + .where(equal("title", "1984")) + .define(field("title").alias("book_title")) + .addFields( + arraySub.toArrayExpression().alias("all_reviewers"), + scalarSub.toScalarExpression().alias("average_rating") + ) + .select("title", "all_reviewers", "average_rating") + .execute() + ) + + assertThat(results.map { it.getData() }) + .containsExactly( + mapOf("title" to "1984", "all_reviewers" to listOf("Alice", "Bob"), "average_rating" to 4.5) + ) + } + + @Test + fun test3LevelDeepJoin() { + val publishersCollName = "publishers_" + autoId() + val booksCollName = "books_" + autoId() + val reviewsCollName = "reviews_" + autoId() + + waitFor( + db + .collection(publishersCollName) + .document("p1") + .set(mapOf("publisherId" to "pub1", "name" to "Penguin")) + ) + + waitFor( + db + .collection(booksCollName) + .document("b1") + .set(mapOf("bookId" to "book1", "publisherId" to "pub1", "title" to "1984")) + ) + + waitFor( + db + .collection(reviewsCollName) + .document("r1") + .set(mapOf("bookId" to "book1", "reviewer" to "Alice")) + ) + + // reviews need to know if the publisher is Penguin + val reviewsSub = + db + .pipeline() + .collection(reviewsCollName) + .where( + com.google.firebase.firestore.pipeline.Expression.and( + equal("bookId", com.google.firebase.firestore.pipeline.Expression.variable("book_id")), + equal( + com.google.firebase.firestore.pipeline.Expression.variable("pub_name"), + "Penguin" + ) // accessing top-level pub_name + ) + ) + .select(field("reviewer").alias("reviewer")) + + val booksSub = + db + .pipeline() + .collection(booksCollName) + .where( + equal("publisherId", com.google.firebase.firestore.pipeline.Expression.variable("pub_id")) + ) + .define(field("bookId").alias("book_id")) + .addFields(reviewsSub.toArrayExpression().alias("reviews")) + .select("title", "reviews") + + val results = + waitFor( + db + .pipeline() + .collection(publishersCollName) + .where(equal("publisherId", "pub1")) + .define(field("publisherId").alias("pub_id"), field("name").alias("pub_name")) + .addFields(booksSub.toArrayExpression().alias("books")) + .select("name", "books") + .execute() + ) + + assertThat(results.map { it.getData() }) + .containsExactly( + mapOf( + "name" to "Penguin", + "books" to listOf(mapOf("title" to "1984", "reviews" to listOf("Alice"))) + ) + ) + } + + // Batch 3: Scope & Variables + + @Test + fun testArraySubqueryInWhereStageOnBooks() { + val reviewsCollName = "reviews_where_" + autoId() + val reviewsCollection = db.collection(reviewsCollName) + + waitFor( + reviewsCollection.document("r1").set(mapOf("bookTitle" to "Dune", "reviewer" to "Paul")) + ) + waitFor( + reviewsCollection.document("r2").set(mapOf("bookTitle" to "Foundation", "reviewer" to "Hari")) + ) + + val reviewsSub = + db + .pipeline() + .collection(reviewsCollName) + .where( + equal( + "bookTitle", + com.google.firebase.firestore.pipeline.Expression.variable("book_title") + ) + ) + .select(field("reviewer").alias("reviewer")) + + val results = + waitFor( + db + .pipeline() + .collection(collection.path) + .where( + com.google.firebase.firestore.pipeline.Expression.or( + equal("title", "Dune"), + equal("title", "The Great Gatsby") + ) + ) + .define(field("title").alias("book_title")) + .where(reviewsSub.toArrayExpression().arrayContains("Paul")) + .select("title") + .execute() + ) + + assertThat(results.map { it.getData() }).containsExactly(mapOf("title" to "Dune")) + } + + @Test + fun testSingleScopeVariableUsage() { + val collName = "single_scope_" + autoId() + waitFor(db.collection(collName).document("doc1").set(mapOf("price" to 100))) + + var results = + waitFor( + db + .pipeline() + .collection(collName) + .define(field("price").multiply(0.8).alias("discount")) + .where( + com.google.firebase.firestore.pipeline.Expression.variable("discount").lessThan(50.0) + ) + .select("price") + .execute() + ) + + assertThat(results).isEmpty() + + waitFor(db.collection(collName).document("doc2").set(mapOf("price" to 50))) + + results = + waitFor( + db + .pipeline() + .collection(collName) + .define(field("price").multiply(0.8).alias("discount")) + .where( + com.google.firebase.firestore.pipeline.Expression.variable("discount").lessThan(50.0) + ) + .select("price") + .execute() + ) + + assertThat(results.map { it.getData() }).containsExactly(mapOf("price" to 50L)) + } + + @Test + fun testExplicitFieldBindingScopeBridging() { + val outerCollName = "outer_scope_" + autoId() + waitFor( + db.collection(outerCollName).document("doc1").set(mapOf("title" to "1984", "id" to "1")) + ) + + val reviewsCollName = "reviews_scope_" + autoId() + waitFor( + db + .collection(reviewsCollName) + .document("r1") + .set(mapOf("bookId" to "1", "reviewer" to "Alice")) + ) + + val reviewsSub = + db + .pipeline() + .collection(reviewsCollName) + .where(equal("bookId", com.google.firebase.firestore.pipeline.Expression.variable("rid"))) + .select(field("reviewer").alias("reviewer")) + + val results = + waitFor( + db + .pipeline() + .collection(outerCollName) + .where(equal("title", "1984")) + .define(field("id").alias("rid")) + .addFields(reviewsSub.toArrayExpression().alias("reviews")) + .select("title", "reviews") + .execute() + ) + + assertThat(results.map { it.getData() }) + .containsExactly(mapOf("title" to "1984", "reviews" to listOf("Alice"))) + } + + @Test + fun testMultipleVariableBindings() { + val outerCollName = "outer_multi_" + autoId() + waitFor( + db + .collection(outerCollName) + .document("doc1") + .set(mapOf("title" to "1984", "id" to "1", "category" to "sci-fi")) + ) + + val reviewsCollName = "reviews_multi_" + autoId() + waitFor( + db + .collection(reviewsCollName) + .document("r1") + .set(mapOf("bookId" to "1", "category" to "sci-fi", "reviewer" to "Alice")) + ) + + val reviewsSub = + db + .pipeline() + .collection(reviewsCollName) + .where( + com.google.firebase.firestore.pipeline.Expression.and( + equal("bookId", com.google.firebase.firestore.pipeline.Expression.variable("rid")), + equal("category", com.google.firebase.firestore.pipeline.Expression.variable("rcat")) + ) + ) + .select(field("reviewer").alias("reviewer")) + + val results = + waitFor( + db + .pipeline() + .collection(outerCollName) + .where(equal("title", "1984")) + .define(field("id").alias("rid"), field("category").alias("rcat")) + .addFields(reviewsSub.toArrayExpression().alias("reviews")) + .select("title", "reviews") + .execute() + ) + + assertThat(results.map { it.getData() }) + .containsExactly(mapOf("title" to "1984", "reviews" to listOf("Alice"))) + } + + @Test + fun testCurrentDocumentBinding() { + val outerCollName = "outer_currentdoc_" + autoId() + waitFor( + db + .collection(outerCollName) + .document("doc1") + .set(mapOf("title" to "1984", "author" to "George Orwell")) + ) + + val reviewsCollName = "reviews_currentdoc_" + autoId() + waitFor( + db + .collection(reviewsCollName) + .document("r1") + .set(mapOf("authorName" to "George Orwell", "reviewer" to "Alice")) + ) + + val reviewsSub = + db + .pipeline() + .collection(reviewsCollName) + .where( + equal( + "authorName", + com.google.firebase.firestore.pipeline.Expression.variable("doc").mapGet("author") + ) + ) + .select(field("reviewer").alias("reviewer")) + + val results = + waitFor( + db + .pipeline() + .collection(outerCollName) + .where(equal("title", "1984")) + .define(com.google.firebase.firestore.pipeline.Expression.currentDocument().alias("doc")) + .addFields(reviewsSub.toArrayExpression().alias("reviews")) + .select("title", "reviews") + .execute() + ) + + assertThat(results.map { it.getData() }) + .containsExactly(mapOf("title" to "1984", "reviews" to listOf("Alice"))) + } + + @Test + fun testUnboundVariableCornerCase() { + val outerCollName = "outer_unbound_" + autoId() + + val exception = + org.junit.Assert.assertThrows(RuntimeException::class.java) { + waitFor( + db + .pipeline() + .collection(outerCollName) + .where( + equal( + "title", + com.google.firebase.firestore.pipeline.Expression.variable("unknown_var") + ) + ) + .execute() + ) + } + + // Assert that it's an API error from the backend complaining about unknown variable + assertThat(exception.cause?.message).contains("unknown variable") + } + + @Test + fun testVariableShadowingCollision() { + val outerCollName = "outer_shadow_" + autoId() + waitFor(db.collection(outerCollName).document("doc1").set(mapOf("title" to "1984"))) + + val innerCollName = "inner_shadow_" + autoId() + waitFor(db.collection(innerCollName).document("i1").set(mapOf("id" to "test"))) + + // Inner subquery re-defines variable "x" to be "inner_val" + val sub = + db + .pipeline() + .collection(innerCollName) + .define(com.google.firebase.firestore.pipeline.Expression.constant("inner_val").alias("x")) + .select(com.google.firebase.firestore.pipeline.Expression.variable("x").alias("val")) + + // Outer pipeline defines variable "x" to be "outer_val" + val results = + waitFor( + db + .pipeline() + .collection(outerCollName) + .where(equal("title", "1984")) + .limit(1) + .define( + com.google.firebase.firestore.pipeline.Expression.constant("outer_val").alias("x") + ) + .addFields(sub.toArrayExpression().alias("shadowed")) + .select("shadowed") + .execute() + ) + + // Due to innermost scope winning, the result should use "inner_val" + // Scalar unwrapping applies because it's a single field + assertThat(results.map { it.getData() }) + .containsExactly(mapOf("shadowed" to listOf("inner_val"))) + } + + @Test + fun testMissingFieldOnCurrentDocument() { + val outerCollName = "outer_missing_" + autoId() + waitFor(db.collection(outerCollName).document("doc1").set(mapOf("title" to "1984"))) + + val reviewsCollName = "reviews_missing_" + autoId() + waitFor( + db + .collection(reviewsCollName) + .document("r1") + .set(mapOf("bookId" to "1", "reviewer" to "Alice")) + ) + + val reviewsSub = + db + .pipeline() + .collection(reviewsCollName) + .where( + equal( + "bookId", + com.google.firebase.firestore.pipeline.Expression.variable("doc") + .mapGet("does_not_exist") + ) + ) + .select(field("reviewer").alias("reviewer")) + + val results = + waitFor( + db + .pipeline() + .collection(outerCollName) + .where(equal("title", "1984")) + .define(com.google.firebase.firestore.pipeline.Expression.currentDocument().alias("doc")) + .addFields(reviewsSub.toArrayExpression().alias("reviews")) + .select("title", "reviews") + .execute() + ) + } + + // Batch 4: Complex & Edge Cases + + @Test + fun testDeepAggregation() { + val outerColl = "outer_agg_" + autoId() + val innerColl = "inner_agg_" + autoId() + + waitFor(db.collection(outerColl).document("doc1").set(mapOf("id" to "1"))) + waitFor(db.collection(outerColl).document("doc2").set(mapOf("id" to "2"))) + + val innerCollection = db.collection(innerColl) + waitFor(innerCollection.document("i1").set(mapOf("outer_id" to "1", "score" to 10))) + waitFor(innerCollection.document("i2").set(mapOf("outer_id" to "2", "score" to 20))) + waitFor(innerCollection.document("i3").set(mapOf("outer_id" to "1", "score" to 30))) + + // subquery calculates the score for the outer doc + val innerSub = + db + .pipeline() + .collection(innerColl) + .where(equal("outer_id", com.google.firebase.firestore.pipeline.Expression.variable("oid"))) + .aggregate( + com.google.firebase.firestore.pipeline.AggregateFunction.average("score").alias("s") + ) + + val results = + waitFor( + db + .pipeline() + .collection(outerColl) + .define(field("id").alias("oid")) + .addFields(innerSub.toScalarExpression().alias("doc_score")) + // Now we aggregate over the calculated subquery results + .aggregate( + com.google.firebase.firestore.pipeline.AggregateFunction.sum("doc_score") + .alias("total_score") + ) + .execute() + ) + + assertThat(results.map { it.getData() }).containsExactly(mapOf("total_score" to 40.0)) + } + + @Test + fun testPipelineStageSupport10Layers() { + val collName = "depth_" + autoId() + waitFor(db.collection(collName).document("doc1").set(mapOf("val" to "hello"))) + + // Create a nested pipeline of depth 10 + var currentSubquery = + db.pipeline().collection(collName).limit(1).select(field("val").alias("val")) + + for (i in 0 until 9) { + currentSubquery = + db + .pipeline() + .collection(collName) + .limit(1) + .addFields(currentSubquery.toArrayExpression().alias("nested_$i")) + .select("nested_$i") + } + + val results = waitFor(currentSubquery.execute()) + assertThat(results).isNotEmpty() + } + + @Ignore("Pending backend support") + @Test + fun testStandardSubcollectionQuery() { + val collName = "subcoll_test_" + autoId() + + waitFor(db.collection(collName).document("doc1").set(mapOf("title" to "1984"))) + + waitFor( + db + .collection(collName) + .document("doc1") + .collection("reviews") + .document("r1") + .set(mapOf("reviewer" to "Alice")) + ) + + val reviewsSub = Pipeline.subcollection("reviews").select(field("reviewer").alias("reviewer")) + + val results = + waitFor( + db + .pipeline() + .collection(collName) + .where(equal("title", "1984")) + .addFields(reviewsSub.toArrayExpression().alias("reviews")) + .select("title", "reviews") + .execute() + ) + + assertThat(results.map { it.getData() }) + .containsExactly(mapOf("title" to "1984", "reviews" to listOf(mapOf("reviewer" to "Alice")))) + } + + @Ignore("Pending backend support") + @Test + fun testMissingSubcollection() { + val collName = "subcoll_missing_" + autoId() + + waitFor(db.collection(collName).document("doc1").set(mapOf("id" to "no_subcollection_here"))) + + // Notably NO subcollections are added to doc1 + + val missingSub = + Pipeline.subcollection("does_not_exist") + .select(com.google.firebase.firestore.pipeline.Expression.variable("p").alias("sub_p")) + + val results = + waitFor( + db + .pipeline() + .collection(collName) + .define( + com.google.firebase.firestore.pipeline.Expression.variable("parentDoc").alias("p") + ) + .select(missingSub.toArrayExpression().alias("missing_data")) + .limit(1) + .execute() + ) + + // Ensure it's not null and evaluates properly to an empty array [] + assertThat(results.map { it.getData() }) + .containsExactly(mapOf("missing_data" to emptyList())) + } + + @Test + fun testDirectExecutionOfSubcollectionPipeline() { + val sub = Pipeline.subcollection("reviews") - // Execute - val results = waitFor(pipeline.execute()) + val exception = + org.junit.Assert.assertThrows(IllegalStateException::class.java) { + // Attempting to execute a relative subcollection pipeline directly should fail + sub.execute() + } - // Check results - assertThat(results).hasSize(1) - val doc = results.first() - assertThat(doc.get("avg_review")).isEqualTo(4.0) + assertThat(exception.message).contains("Cannot execute pipeline without a Firestore instance") } } From a35dcafa55f87a5584b92427b1b9bdf192b982ee Mon Sep 17 00:00:00 2001 From: cherylEnkidu Date: Mon, 2 Mar 2026 12:36:57 -0500 Subject: [PATCH 29/53] Change tests order --- .../firestore/SubqueryIntegrationTest.kt | 474 +++++++++--------- 1 file changed, 237 insertions(+), 237 deletions(-) diff --git a/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/SubqueryIntegrationTest.kt b/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/SubqueryIntegrationTest.kt index 634f97402b4..12b66a93185 100644 --- a/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/SubqueryIntegrationTest.kt +++ b/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/SubqueryIntegrationTest.kt @@ -210,158 +210,6 @@ class SubqueryIntegrationTest { ) } - @Test - fun testScalarSubqueryZeroResults() { - val reviewsCollName = "reviews_zero_" + autoId() - - // No reviews for "1984" - - val reviewsSub = - db - .pipeline() - .collection(reviewsCollName) - .where( - equal( - "bookTitle", - com.google.firebase.firestore.pipeline.Expression.variable("book_title") - ) - ) - .aggregate( - com.google.firebase.firestore.pipeline.AggregateFunction.average("rating").alias("avg") - ) - - val results = - waitFor( - db - .pipeline() - .collection(collection.path) - .where(equal("title", "1984")) // "1984" exists in the main collection from setup - .define(field("title").alias("book_title")) - .addFields(reviewsSub.toScalarExpression().alias("average_rating")) - .select("title", "average_rating") - .execute() - ) - - assertThat(results.map { it.getData() }) - .containsExactly(mapOf("title" to "1984", "average_rating" to null)) - } - - @Test - fun testScalarSubquerySingleAggregationUnwrapping() { - val reviewsCollName = "reviews_agg_single_" + autoId() - - val reviewsCollection = db.collection(reviewsCollName) - waitFor(reviewsCollection.document("r1").set(mapOf("bookTitle" to "1984", "rating" to 4))) - waitFor(reviewsCollection.document("r2").set(mapOf("bookTitle" to "1984", "rating" to 5))) - - val reviewsSub = - db - .pipeline() - .collection(reviewsCollName) - .where( - equal( - "bookTitle", - com.google.firebase.firestore.pipeline.Expression.variable("book_title") - ) - ) - .aggregate( - com.google.firebase.firestore.pipeline.AggregateFunction.average("rating").alias("val") - ) - - val results = - waitFor( - db - .pipeline() - .collection(collection.path) - .where(equal("title", "1984")) - .define(field("title").alias("book_title")) - .addFields(reviewsSub.toScalarExpression().alias("average_rating")) - .select("title", "average_rating") - .execute() - ) - - assertThat(results.map { it.getData() }) - .containsExactly(mapOf("title" to "1984", "average_rating" to 4.5)) - } - - @Test - fun testScalarSubqueryMultipleAggregationsMapWrapping() { - val reviewsCollName = "reviews_agg_multi_" + autoId() - - val reviewsCollection = db.collection(reviewsCollName) - waitFor(reviewsCollection.document("r1").set(mapOf("bookTitle" to "1984", "rating" to 4))) - waitFor(reviewsCollection.document("r2").set(mapOf("bookTitle" to "1984", "rating" to 5))) - - val reviewsSub = - db - .pipeline() - .collection(reviewsCollName) - .where( - equal( - "bookTitle", - com.google.firebase.firestore.pipeline.Expression.variable("book_title") - ) - ) - .aggregate( - com.google.firebase.firestore.pipeline.AggregateFunction.average("rating").alias("avg"), - com.google.firebase.firestore.pipeline.AggregateFunction.countAll().alias("count") - ) - - val results = - waitFor( - db - .pipeline() - .collection(collection.path) - .where(equal("title", "1984")) - .define(field("title").alias("book_title")) - .addFields(reviewsSub.toScalarExpression().alias("stats")) - .select("title", "stats") - .execute() - ) - - assertThat(results.map { it.getData() }) - .containsExactly(mapOf("title" to "1984", "stats" to mapOf("avg" to 4.5, "count" to 2L))) - } - - @Test - fun testScalarSubqueryMultipleResultsRuntimeError() { - val reviewsCollName = "reviews_multiple_" + autoId() - - val reviewsCollection = db.collection(reviewsCollName) - waitFor(reviewsCollection.document("r1").set(mapOf("bookTitle" to "1984", "rating" to 4))) - waitFor(reviewsCollection.document("r2").set(mapOf("bookTitle" to "1984", "rating" to 5))) - - // This subquery will return 2 documents, which is invalid for toScalarExpression() - val reviewsSub = - db - .pipeline() - .collection(reviewsCollName) - .where( - equal( - "bookTitle", - com.google.firebase.firestore.pipeline.Expression.variable("book_title") - ) - ) - - val exception = - org.junit.Assert.assertThrows(RuntimeException::class.java) { - waitFor( - db - .pipeline() - .collection(collection.path) - .where(equal("title", "1984")) - .define(field("title").alias("book_title")) - .addFields(reviewsSub.toScalarExpression().alias("review_data")) - .execute() - ) - } - - // Assert that it's an API error from the backend complaining about multiple results - assertThat(exception.cause?.message).contains("Subpipeline returned multiple results.") - } - - // Batch 2: Array Subqueries & Joins - @Test fun testArraySubqueryJoinAndEmptyResult() { val reviewsCollName = "book_reviews_" + autoId() @@ -534,24 +382,18 @@ class SubqueryIntegrationTest { } @Test - fun testMixedScalarAndArraySubqueries() { - val reviewsCollName = "reviews_mixed_" + autoId() + fun testArraySubqueryInWhereStageOnBooks() { + val reviewsCollName = "reviews_where_" + autoId() val reviewsCollection = db.collection(reviewsCollName) - // Set up some reviews waitFor( - reviewsCollection - .document("r1") - .set(mapOf("bookTitle" to "1984", "reviewer" to "Alice", "rating" to 4)) + reviewsCollection.document("r1").set(mapOf("bookTitle" to "Dune", "reviewer" to "Paul")) ) waitFor( - reviewsCollection - .document("r2") - .set(mapOf("bookTitle" to "1984", "reviewer" to "Bob", "rating" to 5)) + reviewsCollection.document("r2").set(mapOf("bookTitle" to "Foundation", "reviewer" to "Hari")) ) - // Array subquery for all reviewers - val arraySub = + val reviewsSub = db .pipeline() .collection(reviewsCollName) @@ -562,10 +404,36 @@ class SubqueryIntegrationTest { ) ) .select(field("reviewer").alias("reviewer")) - .sort(field("reviewer").ascending()) - // Scalar subquery for the average rating - val scalarSub = + val results = + waitFor( + db + .pipeline() + .collection(collection.path) + .where( + com.google.firebase.firestore.pipeline.Expression.or( + equal("title", "Dune"), + equal("title", "The Great Gatsby") + ) + ) + .define(field("title").alias("book_title")) + .where(reviewsSub.toArrayExpression().arrayContains("Paul")) + .select("title") + .execute() + ) + + assertThat(results.map { it.getData() }).containsExactly(mapOf("title" to "Dune")) + } + + @Test + fun testScalarSubquerySingleAggregationUnwrapping() { + val reviewsCollName = "reviews_agg_single_" + autoId() + + val reviewsCollection = db.collection(reviewsCollName) + waitFor(reviewsCollection.document("r1").set(mapOf("bookTitle" to "1984", "rating" to 4))) + waitFor(reviewsCollection.document("r2").set(mapOf("bookTitle" to "1984", "rating" to 5))) + + val reviewsSub = db .pipeline() .collection(reviewsCollName) @@ -586,110 +454,148 @@ class SubqueryIntegrationTest { .collection(collection.path) .where(equal("title", "1984")) .define(field("title").alias("book_title")) - .addFields( - arraySub.toArrayExpression().alias("all_reviewers"), - scalarSub.toScalarExpression().alias("average_rating") - ) - .select("title", "all_reviewers", "average_rating") + .addFields(reviewsSub.toScalarExpression().alias("average_rating")) + .select("title", "average_rating") .execute() ) assertThat(results.map { it.getData() }) - .containsExactly( - mapOf("title" to "1984", "all_reviewers" to listOf("Alice", "Bob"), "average_rating" to 4.5) - ) + .containsExactly(mapOf("title" to "1984", "average_rating" to 4.5)) } @Test - fun test3LevelDeepJoin() { - val publishersCollName = "publishers_" + autoId() - val booksCollName = "books_" + autoId() - val reviewsCollName = "reviews_" + autoId() - - waitFor( - db - .collection(publishersCollName) - .document("p1") - .set(mapOf("publisherId" to "pub1", "name" to "Penguin")) - ) - - waitFor( - db - .collection(booksCollName) - .document("b1") - .set(mapOf("bookId" to "book1", "publisherId" to "pub1", "title" to "1984")) - ) + fun testScalarSubqueryMultipleAggregationsMapWrapping() { + val reviewsCollName = "reviews_agg_multi_" + autoId() - waitFor( - db - .collection(reviewsCollName) - .document("r1") - .set(mapOf("bookId" to "book1", "reviewer" to "Alice")) - ) + val reviewsCollection = db.collection(reviewsCollName) + waitFor(reviewsCollection.document("r1").set(mapOf("bookTitle" to "1984", "rating" to 4))) + waitFor(reviewsCollection.document("r2").set(mapOf("bookTitle" to "1984", "rating" to 5))) - // reviews need to know if the publisher is Penguin val reviewsSub = db .pipeline() .collection(reviewsCollName) .where( - com.google.firebase.firestore.pipeline.Expression.and( - equal("bookId", com.google.firebase.firestore.pipeline.Expression.variable("book_id")), - equal( - com.google.firebase.firestore.pipeline.Expression.variable("pub_name"), - "Penguin" - ) // accessing top-level pub_name + equal( + "bookTitle", + com.google.firebase.firestore.pipeline.Expression.variable("book_title") ) ) - .select(field("reviewer").alias("reviewer")) + .aggregate( + com.google.firebase.firestore.pipeline.AggregateFunction.average("rating").alias("avg"), + com.google.firebase.firestore.pipeline.AggregateFunction.countAll().alias("count") + ) - val booksSub = + val results = + waitFor( + db + .pipeline() + .collection(collection.path) + .where(equal("title", "1984")) + .define(field("title").alias("book_title")) + .addFields(reviewsSub.toScalarExpression().alias("stats")) + .select("title", "stats") + .execute() + ) + + assertThat(results.map { it.getData() }) + .containsExactly(mapOf("title" to "1984", "stats" to mapOf("avg" to 4.5, "count" to 2L))) + } + + @Test + fun testScalarSubqueryZeroResults() { + val reviewsCollName = "reviews_zero_" + autoId() + + // No reviews for "1984" + + val reviewsSub = db .pipeline() - .collection(booksCollName) + .collection(reviewsCollName) .where( - equal("publisherId", com.google.firebase.firestore.pipeline.Expression.variable("pub_id")) + equal( + "bookTitle", + com.google.firebase.firestore.pipeline.Expression.variable("book_title") + ) + ) + .aggregate( + com.google.firebase.firestore.pipeline.AggregateFunction.average("rating").alias("avg") ) - .define(field("bookId").alias("book_id")) - .addFields(reviewsSub.toArrayExpression().alias("reviews")) - .select("title", "reviews") val results = waitFor( db .pipeline() - .collection(publishersCollName) - .where(equal("publisherId", "pub1")) - .define(field("publisherId").alias("pub_id"), field("name").alias("pub_name")) - .addFields(booksSub.toArrayExpression().alias("books")) - .select("name", "books") + .collection(collection.path) + .where(equal("title", "1984")) // "1984" exists in the main collection from setup + .define(field("title").alias("book_title")) + .addFields(reviewsSub.toScalarExpression().alias("average_rating")) + .select("title", "average_rating") .execute() ) assertThat(results.map { it.getData() }) - .containsExactly( - mapOf( - "name" to "Penguin", - "books" to listOf(mapOf("title" to "1984", "reviews" to listOf("Alice"))) + .containsExactly(mapOf("title" to "1984", "average_rating" to null)) + } + + @Test + fun testScalarSubqueryMultipleResultsRuntimeError() { + val reviewsCollName = "reviews_multiple_" + autoId() + + val reviewsCollection = db.collection(reviewsCollName) + waitFor(reviewsCollection.document("r1").set(mapOf("bookTitle" to "1984", "rating" to 4))) + waitFor(reviewsCollection.document("r2").set(mapOf("bookTitle" to "1984", "rating" to 5))) + + // This subquery will return 2 documents, which is invalid for toScalarExpression() + val reviewsSub = + db + .pipeline() + .collection(reviewsCollName) + .where( + equal( + "bookTitle", + com.google.firebase.firestore.pipeline.Expression.variable("book_title") + ) ) - ) + + val exception = + org.junit.Assert.assertThrows(RuntimeException::class.java) { + waitFor( + db + .pipeline() + .collection(collection.path) + .where(equal("title", "1984")) + .define(field("title").alias("book_title")) + .addFields(reviewsSub.toScalarExpression().alias("review_data")) + .execute() + ) + } + + // Assert that it's an API error from the backend complaining about multiple results + assertThat(exception.cause?.message).contains("Subpipeline returned multiple results.") } - // Batch 3: Scope & Variables + // Batch 2: Array Subqueries & Joins @Test - fun testArraySubqueryInWhereStageOnBooks() { - val reviewsCollName = "reviews_where_" + autoId() + fun testMixedScalarAndArraySubqueries() { + val reviewsCollName = "reviews_mixed_" + autoId() val reviewsCollection = db.collection(reviewsCollName) + // Set up some reviews waitFor( - reviewsCollection.document("r1").set(mapOf("bookTitle" to "Dune", "reviewer" to "Paul")) + reviewsCollection + .document("r1") + .set(mapOf("bookTitle" to "1984", "reviewer" to "Alice", "rating" to 4)) ) waitFor( - reviewsCollection.document("r2").set(mapOf("bookTitle" to "Foundation", "reviewer" to "Hari")) + reviewsCollection + .document("r2") + .set(mapOf("bookTitle" to "1984", "reviewer" to "Bob", "rating" to 5)) ) - val reviewsSub = + // Array subquery for all reviewers + val arraySub = db .pipeline() .collection(reviewsCollName) @@ -700,25 +606,42 @@ class SubqueryIntegrationTest { ) ) .select(field("reviewer").alias("reviewer")) + .sort(field("reviewer").ascending()) + + // Scalar subquery for the average rating + val scalarSub = + db + .pipeline() + .collection(reviewsCollName) + .where( + equal( + "bookTitle", + com.google.firebase.firestore.pipeline.Expression.variable("book_title") + ) + ) + .aggregate( + com.google.firebase.firestore.pipeline.AggregateFunction.average("rating").alias("val") + ) val results = waitFor( db .pipeline() .collection(collection.path) - .where( - com.google.firebase.firestore.pipeline.Expression.or( - equal("title", "Dune"), - equal("title", "The Great Gatsby") - ) - ) + .where(equal("title", "1984")) .define(field("title").alias("book_title")) - .where(reviewsSub.toArrayExpression().arrayContains("Paul")) - .select("title") + .addFields( + arraySub.toArrayExpression().alias("all_reviewers"), + scalarSub.toScalarExpression().alias("average_rating") + ) + .select("title", "all_reviewers", "average_rating") .execute() ) - assertThat(results.map { it.getData() }).containsExactly(mapOf("title" to "Dune")) + assertThat(results.map { it.getData() }) + .containsExactly( + mapOf("title" to "1984", "all_reviewers" to listOf("Alice", "Bob"), "average_rating" to 4.5) + ) } @Test @@ -992,6 +915,83 @@ class SubqueryIntegrationTest { // Batch 4: Complex & Edge Cases + @Test + fun test3LevelDeepJoin() { + val publishersCollName = "publishers_" + autoId() + val booksCollName = "books_" + autoId() + val reviewsCollName = "reviews_" + autoId() + + waitFor( + db + .collection(publishersCollName) + .document("p1") + .set(mapOf("publisherId" to "pub1", "name" to "Penguin")) + ) + + waitFor( + db + .collection(booksCollName) + .document("b1") + .set(mapOf("bookId" to "book1", "publisherId" to "pub1", "title" to "1984")) + ) + + waitFor( + db + .collection(reviewsCollName) + .document("r1") + .set(mapOf("bookId" to "book1", "reviewer" to "Alice")) + ) + + // reviews need to know if the publisher is Penguin + val reviewsSub = + db + .pipeline() + .collection(reviewsCollName) + .where( + com.google.firebase.firestore.pipeline.Expression.and( + equal("bookId", com.google.firebase.firestore.pipeline.Expression.variable("book_id")), + equal( + com.google.firebase.firestore.pipeline.Expression.variable("pub_name"), + "Penguin" + ) // accessing top-level pub_name + ) + ) + .select(field("reviewer").alias("reviewer")) + + val booksSub = + db + .pipeline() + .collection(booksCollName) + .where( + equal("publisherId", com.google.firebase.firestore.pipeline.Expression.variable("pub_id")) + ) + .define(field("bookId").alias("book_id")) + .addFields(reviewsSub.toArrayExpression().alias("reviews")) + .select("title", "reviews") + + val results = + waitFor( + db + .pipeline() + .collection(publishersCollName) + .where(equal("publisherId", "pub1")) + .define(field("publisherId").alias("pub_id"), field("name").alias("pub_name")) + .addFields(booksSub.toArrayExpression().alias("books")) + .select("name", "books") + .execute() + ) + + assertThat(results.map { it.getData() }) + .containsExactly( + mapOf( + "name" to "Penguin", + "books" to listOf(mapOf("title" to "1984", "reviews" to listOf("Alice"))) + ) + ) + } + + // Batch 3: Scope & Variables + @Test fun testDeepAggregation() { val outerColl = "outer_agg_" + autoId() From 76ea75a5c3b8187f1d2d836a153773729bc2770a Mon Sep 17 00:00:00 2001 From: cherylEnkidu Date: Tue, 3 Mar 2026 16:10:16 -0500 Subject: [PATCH 30/53] address PR review comments --- firebase-firestore/api.txt | 10 +- .../firestore/SubqueryIntegrationTest.kt | 179 ++++------------ .../com/google/firebase/firestore/Pipeline.kt | 192 ++++++++++-------- .../firestore/pipeline/expressions.kt | 23 +-- .../firebase/firestore/pipeline/stage.kt | 7 +- .../pipeline/SubqueryPipelineTests.kt | 122 ----------- 6 files changed, 170 insertions(+), 363 deletions(-) delete mode 100644 firebase-firestore/src/test/java/com/google/firebase/firestore/pipeline/SubqueryPipelineTests.kt diff --git a/firebase-firestore/api.txt b/firebase-firestore/api.txt index 16a0419b7dc..4233fe64807 100644 --- a/firebase-firestore/api.txt +++ b/firebase-firestore/api.txt @@ -445,6 +445,7 @@ package com.google.firebase.firestore { method public com.google.firebase.firestore.Pipeline select(com.google.firebase.firestore.pipeline.Selectable selection, java.lang.Object... additionalSelections); method public com.google.firebase.firestore.Pipeline select(String fieldName, java.lang.Object... additionalSelections); method public com.google.firebase.firestore.Pipeline sort(com.google.firebase.firestore.pipeline.Ordering order, com.google.firebase.firestore.pipeline.Ordering... additionalOrders); + method public static com.google.firebase.firestore.Pipeline subcollection(com.google.firebase.firestore.pipeline.SubcollectionSource source); method public static com.google.firebase.firestore.Pipeline subcollection(String path); method public com.google.firebase.firestore.pipeline.Expression toArrayExpression(); method public com.google.firebase.firestore.pipeline.Expression toScalarExpression(); @@ -458,6 +459,7 @@ package com.google.firebase.firestore { } public static final class Pipeline.Companion { + method public com.google.firebase.firestore.Pipeline subcollection(com.google.firebase.firestore.pipeline.SubcollectionSource source); method public com.google.firebase.firestore.Pipeline subcollection(String path); } @@ -1690,7 +1692,13 @@ package com.google.firebase.firestore.pipeline { method public final T withOption(String key, long value); } - public final class SubcollectionSource extends com.google.firebase.firestore.pipeline.Stage { + @com.google.common.annotations.Beta public final class SubcollectionSource extends com.google.firebase.firestore.pipeline.Stage { + method public static com.google.firebase.firestore.pipeline.SubcollectionSource of(String path); + field public static final com.google.firebase.firestore.pipeline.SubcollectionSource.Companion Companion; + } + + public static final class SubcollectionSource.Companion { + method public com.google.firebase.firestore.pipeline.SubcollectionSource of(String path); } @com.google.common.annotations.Beta public final class UnnestOptions extends com.google.firebase.firestore.pipeline.AbstractOptions { diff --git a/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/SubqueryIntegrationTest.kt b/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/SubqueryIntegrationTest.kt index 12b66a93185..bd04ec8fc8e 100644 --- a/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/SubqueryIntegrationTest.kt +++ b/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/SubqueryIntegrationTest.kt @@ -18,8 +18,12 @@ package com.google.firebase.firestore import androidx.test.ext.junit.runners.AndroidJUnit4 import com.google.common.truth.Truth.assertThat +import com.google.firebase.firestore.pipeline.Expression.Companion.and +import com.google.firebase.firestore.pipeline.Expression.Companion.constant +import com.google.firebase.firestore.pipeline.Expression.Companion.currentDocument import com.google.firebase.firestore.pipeline.Expression.Companion.equal import com.google.firebase.firestore.pipeline.Expression.Companion.field +import com.google.firebase.firestore.pipeline.Expression.Companion.or import com.google.firebase.firestore.pipeline.Expression.Companion.variable import com.google.firebase.firestore.testutil.IntegrationTestUtil import com.google.firebase.firestore.testutil.IntegrationTestUtil.waitFor @@ -183,8 +187,6 @@ class SubqueryIntegrationTest { IntegrationTestUtil.tearDown() } - // Batch 1: Scalar Subqueries - @Test fun testZeroResultScalarReturnsNull() { val testDocs = mapOf("book1" to mapOf("title" to "A Book Title")) @@ -197,7 +199,7 @@ class SubqueryIntegrationTest { .pipeline() .collection(collection.document("book1").collection("reviews").path) .where(equal("reviewer", "Alice")) - .select(com.google.firebase.firestore.pipeline.Expression.currentDocument().alias("data")) + .select(currentDocument().alias("data")) val results = waitFor( @@ -208,6 +210,8 @@ class SubqueryIntegrationTest { .limit(1) .execute() ) + + assertThat(results.map { it.getData() }).containsExactly(mapOf("first_review_data" to null)) } @Test @@ -228,12 +232,7 @@ class SubqueryIntegrationTest { db .pipeline() .collection(reviewsCollName) - .where( - equal( - "bookTitle", - com.google.firebase.firestore.pipeline.Expression.variable("book_title") - ) - ) + .where(equal("bookTitle", variable("book_title"))) .select(field("reviewer").alias("reviewer")) .sort(field("reviewer").ascending()) @@ -243,7 +242,7 @@ class SubqueryIntegrationTest { .pipeline() .collection(collection.path) .where( - com.google.firebase.firestore.pipeline.Expression.or( + or( equal("title", "The Hitchhiker's Guide to the Galaxy"), equal("title", "Pride and Prejudice") ) @@ -286,24 +285,14 @@ class SubqueryIntegrationTest { db .pipeline() .collection(reviewsCollName) - .where( - equal( - "bookTitle", - com.google.firebase.firestore.pipeline.Expression.variable("book_title") - ) - ) + .where(equal("bookTitle", variable("book_title"))) .select(field("rating").alias("rating")) val authorsSub = db .pipeline() .collection(authorsCollName) - .where( - equal( - "authorName", - com.google.firebase.firestore.pipeline.Expression.variable("author_name") - ) - ) + .where(equal("authorName", variable("author_name"))) .select(field("nationality").alias("nationality")) val results = @@ -347,12 +336,7 @@ class SubqueryIntegrationTest { db .pipeline() .collection(reviewsCollName) - .where( - equal( - "bookTitle", - com.google.firebase.firestore.pipeline.Expression.variable("book_title") - ) - ) + .where(equal("bookTitle", variable("book_title"))) .select(field("reviewer").alias("reviewer"), field("rating").alias("rating")) .sort(field("reviewer").ascending()) @@ -397,12 +381,7 @@ class SubqueryIntegrationTest { db .pipeline() .collection(reviewsCollName) - .where( - equal( - "bookTitle", - com.google.firebase.firestore.pipeline.Expression.variable("book_title") - ) - ) + .where(equal("bookTitle", variable("book_title"))) .select(field("reviewer").alias("reviewer")) val results = @@ -410,12 +389,7 @@ class SubqueryIntegrationTest { db .pipeline() .collection(collection.path) - .where( - com.google.firebase.firestore.pipeline.Expression.or( - equal("title", "Dune"), - equal("title", "The Great Gatsby") - ) - ) + .where(or(equal("title", "Dune"), equal("title", "The Great Gatsby"))) .define(field("title").alias("book_title")) .where(reviewsSub.toArrayExpression().arrayContains("Paul")) .select("title") @@ -437,12 +411,7 @@ class SubqueryIntegrationTest { db .pipeline() .collection(reviewsCollName) - .where( - equal( - "bookTitle", - com.google.firebase.firestore.pipeline.Expression.variable("book_title") - ) - ) + .where(equal("bookTitle", variable("book_title"))) .aggregate( com.google.firebase.firestore.pipeline.AggregateFunction.average("rating").alias("val") ) @@ -475,12 +444,7 @@ class SubqueryIntegrationTest { db .pipeline() .collection(reviewsCollName) - .where( - equal( - "bookTitle", - com.google.firebase.firestore.pipeline.Expression.variable("book_title") - ) - ) + .where(equal("bookTitle", variable("book_title"))) .aggregate( com.google.firebase.firestore.pipeline.AggregateFunction.average("rating").alias("avg"), com.google.firebase.firestore.pipeline.AggregateFunction.countAll().alias("count") @@ -512,12 +476,7 @@ class SubqueryIntegrationTest { db .pipeline() .collection(reviewsCollName) - .where( - equal( - "bookTitle", - com.google.firebase.firestore.pipeline.Expression.variable("book_title") - ) - ) + .where(equal("bookTitle", variable("book_title"))) .aggregate( com.google.firebase.firestore.pipeline.AggregateFunction.average("rating").alias("avg") ) @@ -548,15 +507,7 @@ class SubqueryIntegrationTest { // This subquery will return 2 documents, which is invalid for toScalarExpression() val reviewsSub = - db - .pipeline() - .collection(reviewsCollName) - .where( - equal( - "bookTitle", - com.google.firebase.firestore.pipeline.Expression.variable("book_title") - ) - ) + db.pipeline().collection(reviewsCollName).where(equal("bookTitle", variable("book_title"))) val exception = org.junit.Assert.assertThrows(RuntimeException::class.java) { @@ -575,8 +526,6 @@ class SubqueryIntegrationTest { assertThat(exception.cause?.message).contains("Subpipeline returned multiple results.") } - // Batch 2: Array Subqueries & Joins - @Test fun testMixedScalarAndArraySubqueries() { val reviewsCollName = "reviews_mixed_" + autoId() @@ -599,12 +548,7 @@ class SubqueryIntegrationTest { db .pipeline() .collection(reviewsCollName) - .where( - equal( - "bookTitle", - com.google.firebase.firestore.pipeline.Expression.variable("book_title") - ) - ) + .where(equal("bookTitle", variable("book_title"))) .select(field("reviewer").alias("reviewer")) .sort(field("reviewer").ascending()) @@ -613,12 +557,7 @@ class SubqueryIntegrationTest { db .pipeline() .collection(reviewsCollName) - .where( - equal( - "bookTitle", - com.google.firebase.firestore.pipeline.Expression.variable("book_title") - ) - ) + .where(equal("bookTitle", variable("book_title"))) .aggregate( com.google.firebase.firestore.pipeline.AggregateFunction.average("rating").alias("val") ) @@ -655,9 +594,7 @@ class SubqueryIntegrationTest { .pipeline() .collection(collName) .define(field("price").multiply(0.8).alias("discount")) - .where( - com.google.firebase.firestore.pipeline.Expression.variable("discount").lessThan(50.0) - ) + .where(variable("discount").lessThan(50.0)) .select("price") .execute() ) @@ -672,9 +609,7 @@ class SubqueryIntegrationTest { .pipeline() .collection(collName) .define(field("price").multiply(0.8).alias("discount")) - .where( - com.google.firebase.firestore.pipeline.Expression.variable("discount").lessThan(50.0) - ) + .where(variable("discount").lessThan(50.0)) .select("price") .execute() ) @@ -701,7 +636,7 @@ class SubqueryIntegrationTest { db .pipeline() .collection(reviewsCollName) - .where(equal("bookId", com.google.firebase.firestore.pipeline.Expression.variable("rid"))) + .where(equal("bookId", variable("rid"))) .select(field("reviewer").alias("reviewer")) val results = @@ -742,12 +677,7 @@ class SubqueryIntegrationTest { db .pipeline() .collection(reviewsCollName) - .where( - com.google.firebase.firestore.pipeline.Expression.and( - equal("bookId", com.google.firebase.firestore.pipeline.Expression.variable("rid")), - equal("category", com.google.firebase.firestore.pipeline.Expression.variable("rcat")) - ) - ) + .where(and(equal("bookId", variable("rid")), equal("category", variable("rcat")))) .select(field("reviewer").alias("reviewer")) val results = @@ -788,12 +718,7 @@ class SubqueryIntegrationTest { db .pipeline() .collection(reviewsCollName) - .where( - equal( - "authorName", - com.google.firebase.firestore.pipeline.Expression.variable("doc").mapGet("author") - ) - ) + .where(equal("authorName", variable("doc").mapGet("author"))) .select(field("reviewer").alias("reviewer")) val results = @@ -802,7 +727,7 @@ class SubqueryIntegrationTest { .pipeline() .collection(outerCollName) .where(equal("title", "1984")) - .define(com.google.firebase.firestore.pipeline.Expression.currentDocument().alias("doc")) + .define(currentDocument().alias("doc")) .addFields(reviewsSub.toArrayExpression().alias("reviews")) .select("title", "reviews") .execute() @@ -822,12 +747,7 @@ class SubqueryIntegrationTest { db .pipeline() .collection(outerCollName) - .where( - equal( - "title", - com.google.firebase.firestore.pipeline.Expression.variable("unknown_var") - ) - ) + .where(equal("title", variable("unknown_var"))) .execute() ) } @@ -849,8 +769,8 @@ class SubqueryIntegrationTest { db .pipeline() .collection(innerCollName) - .define(com.google.firebase.firestore.pipeline.Expression.constant("inner_val").alias("x")) - .select(com.google.firebase.firestore.pipeline.Expression.variable("x").alias("val")) + .define(constant("inner_val").alias("x")) + .select(variable("x").alias("val")) // Outer pipeline defines variable "x" to be "outer_val" val results = @@ -860,9 +780,7 @@ class SubqueryIntegrationTest { .collection(outerCollName) .where(equal("title", "1984")) .limit(1) - .define( - com.google.firebase.firestore.pipeline.Expression.constant("outer_val").alias("x") - ) + .define(constant("outer_val").alias("x")) .addFields(sub.toArrayExpression().alias("shadowed")) .select("shadowed") .execute() @@ -891,13 +809,7 @@ class SubqueryIntegrationTest { db .pipeline() .collection(reviewsCollName) - .where( - equal( - "bookId", - com.google.firebase.firestore.pipeline.Expression.variable("doc") - .mapGet("does_not_exist") - ) - ) + .where(equal("bookId", variable("doc").mapGet("does_not_exist"))) .select(field("reviewer").alias("reviewer")) val results = @@ -906,15 +818,13 @@ class SubqueryIntegrationTest { .pipeline() .collection(outerCollName) .where(equal("title", "1984")) - .define(com.google.firebase.firestore.pipeline.Expression.currentDocument().alias("doc")) + .define(currentDocument().alias("doc")) .addFields(reviewsSub.toArrayExpression().alias("reviews")) .select("title", "reviews") .execute() ) } - // Batch 4: Complex & Edge Cases - @Test fun test3LevelDeepJoin() { val publishersCollName = "publishers_" + autoId() @@ -948,12 +858,9 @@ class SubqueryIntegrationTest { .pipeline() .collection(reviewsCollName) .where( - com.google.firebase.firestore.pipeline.Expression.and( - equal("bookId", com.google.firebase.firestore.pipeline.Expression.variable("book_id")), - equal( - com.google.firebase.firestore.pipeline.Expression.variable("pub_name"), - "Penguin" - ) // accessing top-level pub_name + and( + equal("bookId", variable("book_id")), + equal(variable("pub_name"), "Penguin") // accessing top-level pub_name ) ) .select(field("reviewer").alias("reviewer")) @@ -962,9 +869,7 @@ class SubqueryIntegrationTest { db .pipeline() .collection(booksCollName) - .where( - equal("publisherId", com.google.firebase.firestore.pipeline.Expression.variable("pub_id")) - ) + .where(equal("publisherId", variable("pub_id"))) .define(field("bookId").alias("book_id")) .addFields(reviewsSub.toArrayExpression().alias("reviews")) .select("title", "reviews") @@ -990,8 +895,6 @@ class SubqueryIntegrationTest { ) } - // Batch 3: Scope & Variables - @Test fun testDeepAggregation() { val outerColl = "outer_agg_" + autoId() @@ -1010,7 +913,7 @@ class SubqueryIntegrationTest { db .pipeline() .collection(innerColl) - .where(equal("outer_id", com.google.firebase.firestore.pipeline.Expression.variable("oid"))) + .where(equal("outer_id", variable("oid"))) .aggregate( com.google.firebase.firestore.pipeline.AggregateFunction.average("score").alias("s") ) @@ -1098,18 +1001,14 @@ class SubqueryIntegrationTest { // Notably NO subcollections are added to doc1 - val missingSub = - Pipeline.subcollection("does_not_exist") - .select(com.google.firebase.firestore.pipeline.Expression.variable("p").alias("sub_p")) + val missingSub = Pipeline.subcollection("does_not_exist").select(variable("p").alias("sub_p")) val results = waitFor( db .pipeline() .collection(collName) - .define( - com.google.firebase.firestore.pipeline.Expression.variable("parentDoc").alias("p") - ) + .define(variable("parentDoc").alias("p")) .select(missingSub.toArrayExpression().alias("missing_data")) .limit(1) .execute() diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/Pipeline.kt b/firebase-firestore/src/main/java/com/google/firebase/firestore/Pipeline.kt index 9c618a3266b..aab57f9e72f 100644 --- a/firebase-firestore/src/main/java/com/google/firebase/firestore/Pipeline.kt +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/Pipeline.kt @@ -162,7 +162,7 @@ internal constructor( } private fun toExecutePipelineRequest(options: InternalOptions?): ExecutePipelineRequest { - checkNotNull(firestore) { "Cannot execute pipeline without a Firestore instance" } + checkNotNull(firestore) { "Cannot execute a relative subcollection pipeline directly" } val database = firestore!!.databaseId val builder = ExecutePipelineRequest.newBuilder() builder.database = "projects/${database.projectId}/databases/${database.databaseId}" @@ -975,7 +975,65 @@ internal constructor( fun define(stage: DefineStage): Pipeline = append(stage) /** - * Converts this pipeline to an expression that evaluates to an array of results. + * Converts this Pipeline into an expression that evaluates to an array of results. + * + * **Result Unwrapping:** + * - If the items have a single field, their values are unwrapped and returned directly in the + * array. + * - If the items have multiple fields, they are returned as Maps in the array. + * + * Example: + * ```kotlin + * // Get a list of reviewers for each book + * db.pipeline().collection("books") + * .define(field("id").alias("book_id")) + * .addFields( + * db.pipeline().collection("reviews") + * .where(field("book_id").equal(variable("book_id"))) + * .select(field("reviewer")) + * .toArrayExpression() + * .alias("reviewers")) + * ``` + * + * Output: + * ```json + * [ + * { + * "id": "1", + * "title": "1984", + * "reviewers": ["Alice", "Bob"] + * } + * ] + * ``` + * + * Example (Multiple Fields): + * ```kotlin + * // Get a list of reviews (reviewer and rating) for each book + * db.pipeline().collection("books") + * .define(field("id").alias("book_id")) + * .addFields( + * db.pipeline().collection("reviews") + * .where(field("book_id").equal(variable("book_id"))) + * .select(field("reviewer"), field("rating")) + * .toArrayExpression() + * .alias("reviews")) + * ``` + * + * *When the subquery produces multiple fields, they are kept as objects in the array:* + * + * Output: + * ```json + * [ + * { + * "id": "1", + * "title": "1984", + * "reviews": [ + * { "reviewer": "Alice", "rating": 5 }, + * { "reviewer": "Bob", "rating": 4 } + * ] + * } + * ] + * ``` * * @return An [Expression] that executes this pipeline and returns the results as a list. */ @@ -984,119 +1042,76 @@ internal constructor( } /** - * Converts this Pipeline into an expression that evaluates to a single scalar result. Used for - * 1:1 lookups or Aggregations when the subquery is expected to return a single value or object. + * Converts this Pipeline into an expression that evaluates to a single scalar result. * - * **Runtime Validation:** The runtime will validate that the result set contains exactly one - * item. It throws a runtime error if the result has more than one item, and evaluates to `null` - * if the pipeline has zero results. + * **Runtime Validation:** The runtime validates that the result set contains zero or one item. If + * zero items, it evaluates to `null`. * - * **Result Unwrapping:** For simpler access, scalar subqueries producing a single field - * automatically unwrap that value to the top level, ignoring the inner alias. If the subquery - * returns multiple fields, they are preserved as a map. + * **Result Unwrapping:** If the result contains exactly one item: + * - If the item has a single field, its value is unwrapped and returned directly. + * - If the item has multiple fields, they are returned as a Map. * - * **Example 1: Single field unwrapping** + * Example: * ```kotlin - * // Calculate average rating for each restaurant using a subquery - * db.pipeline().collection("restaurants") - * .define(field("id").alias("rid")) - * .addFields( + * // Calculate average rating for a restaurant + * db.pipeline().collection("restaurants").addFields( * db.pipeline().collection("reviews") - * .where(field("restaurant_id").equal(variable("rid"))) - * // Inner aggregation returns a single document - * .aggregate(AggregateFunction.average("rating").alias("value")) - * // Convert Pipeline -> Scalar Expression (validates result is 1 item) - * .toScalarExpression() - * .alias("average_rating") + * .where(field("restaurant_id").equal(variable("rid"))) + * .aggregate(AggregateFunction.average("rating").alias("avg")) + * // Unwraps the single "avg" field to a scalar double + * .toScalarExpression().alias("average_rating") * ) * ``` * - * *The result set is unwrapped twice: from `"average_rating": [{ "value": 4.5 }]` to - * `"average_rating": { "value": 4.5 }`, and finally to `"average_rating": 4.5`.* - * + * Output: * ```json - * // Output Document: - * [ - * { - * "id": "123", - * "name": "The Burger Joint", - * "cuisine": "American", - * "average_rating": 4.5 - * }, - * { - * "id": "456", - * "name": "Sushi World", - * "cuisine": "Japanese", - * "average_rating": 4.8 - * } - * ] + * { + * "name": "The Burger Joint", + * "average_rating": 4.5 + * } * ``` * - * **Example 2: Multiple fields (Map)** + * Example (Multiple Fields): * ```kotlin - * // For each restaurant, calculate review statistics (average rating AND total count) - * db.pipeline().collection("restaurants") - * .define(field("id").alias("rid")) - * .addFields( + * // Calculate average rating AND count for a restaurant + * db.pipeline().collection("restaurants").addFields( * db.pipeline().collection("reviews") - * .where(field("restaurant_id").equal(variable("rid"))) - * .aggregate( - * AggregateFunction.average("rating").alias("avg_score"), - * AggregateFunction.countAll().alias("review_count") - * ) - * .toScalarExpression() - * .alias("stats") + * .where(field("restaurant_id").equal(variable("rid"))) + * .aggregate( + * AggregateFunction.average("rating").alias("avg"), + * AggregateFunction.count().alias("count") + * ) + * // Returns a Map with "avg" and "count" fields + * .toScalarExpression().alias("stats") * ) * ``` * - * *When the subquery produces multiple fields, they are wrapped in a map:* - * + * Output: * ```json - * // Output Document: - * [ - * { - * "id": "123", - * "name": "The Burger Joint", - * "cuisine": "American", - * "stats": { - * "avg_score": 4.0, - * "review_count": 3 - * } - * }, - * { - * "id": "456", - * "name": "Sushi World", - * "cuisine": "Japanese", - * "stats": { - * "avg_score": 4.8, - * "review_count": 120 - * } + * { + * "name": "The Burger Joint", + * "stats": { + * "avg": 4.5, + * "count": 100 * } - * ] + * } * ``` * - * @return An [Expression] representing the execution of this pipeline. + * @return An [Expression] representing the scalar result. */ fun toScalarExpression(): Expression { return FunctionExpression("scalar", notImplemented, Expression.toExprOrConstant(this)) } companion object { - /** - * Creates a pipeline that processes the documents in the specified subcollection of the current - * document. - * - * @param path The relative path to the subcollection. - * @return A new [Pipeline] scoped to the subcollection. - */ /** * Initializes a pipeline scoped to a subcollection. * * This method allows you to start a new pipeline that operates on a subcollection of the * current document. It is intended to be used as a subquery. * - * **Note:** A pipeline created with `subcollection` cannot be executed directly using - * [Pipeline.snapshot]. It must be used within a parent pipeline. + * **Note:** A pipeline created with `subcollection` cannot be executed directly. It must be + * used within a parent pipeline. * * Example: * ``` @@ -1119,11 +1134,20 @@ internal constructor( * Creates a pipeline that processes the documents in the specified subcollection of the current * document. * + * Example: + * ``` + * firestore.pipeline().collection("books") + * .addFields( + * Pipeline.subcollection(SubcollectionSource.of("reviews")) + * .aggregate(AggregateFunction.average("rating").as("avg_rating")) + * .toScalarExpression().as("average_rating")); + * ``` + * * @param source The subcollection that will be the source of this pipeline. * @return A new [Pipeline] scoped to the subcollection. */ @JvmStatic - internal fun subcollection(source: SubcollectionSource): Pipeline { + fun subcollection(source: SubcollectionSource): Pipeline { return Pipeline(null, null, source) } } diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/expressions.kt b/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/expressions.kt index ed7f2043b6a..4606aa800b0 100644 --- a/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/expressions.kt +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/expressions.kt @@ -2975,6 +2975,7 @@ abstract class Expression internal constructor() { * Accesses a field/property of the expression (When the expression evaluates to a Map or * Document). * + * @param expression The expression evaluating to a map or document. * @param key The key of the field to access. * @return An [Expression] representing the value of the field. */ @@ -2983,7 +2984,7 @@ abstract class Expression internal constructor() { FunctionExpression("field", notImplemented, expression, key) /** - * Accesses a field/property of a document field using the provided [key]. + * Accesses a field/property of a document or Map using the provided [key]. * * @param fieldName The field name of the map or document field. * @param key The key of the field to access. @@ -3005,7 +3006,7 @@ abstract class Expression internal constructor() { FunctionExpression("field", notImplemented, expression, keyExpression) /** - * Accesses a field/property of a document field using the provided [keyExpression]. + * Accesses a field/property of a document or Map using the provided [keyExpression]. * * @param fieldName The field name of the map or document field. * @param keyExpression The expression evaluating to the key. @@ -5336,7 +5337,7 @@ abstract class Expression internal constructor() { * ``` * // Define a variable "discountedPrice" and use it in a filter * firestore.pipeline().collection("products") - * .define(Constant(100).as("threshold")) + * .define(Constant(100).alias("threshold")) * .where(lessThan(variable("discountedPrice"), variable("threshold"))); * ``` * @@ -5352,7 +5353,7 @@ abstract class Expression internal constructor() { * ``` * // Define the current document as a variable "doc" * firestore.pipeline().collection("books") - * .define(currentDocument().as("doc")) + * .define(currentDocument().alias("doc")) * // Access a field from the defined document variable * .select(variable("doc").getField("title")); * ``` @@ -7503,13 +7504,13 @@ internal constructor( name: String, function: EvaluateFunction, fieldName: String - ) : this(name, function, arrayOf(Expression.field(fieldName))) + ) : this(name, function, arrayOf(field(fieldName))) internal constructor( name: String, function: EvaluateFunction, fieldName: String, vararg params: Any - ) : this(name, function, arrayOf(Expression.field(fieldName), *toArrayOfExprOrConstant(params))) + ) : this(name, function, arrayOf(field(fieldName), *toArrayOfExprOrConstant(params))) override fun toProto(userDataReader: UserDataReader): Value { val builder = ProtoFunction.newBuilder() @@ -7655,17 +7656,13 @@ internal class BooleanFunctionExpression internal constructor(val expr: Expressi name: String, function: EvaluateFunction, fieldName: String - ) : this(name, function, arrayOf(Expression.field(fieldName))) + ) : this(name, function, arrayOf(field(fieldName))) internal constructor( name: String, function: EvaluateFunction, fieldName: String, vararg params: Any - ) : this( - name, - function, - arrayOf(Expression.field(fieldName), *Expression.toArrayOfExprOrConstant(params)) - ) + ) : this(name, function, arrayOf(field(fieldName), *Expression.toArrayOfExprOrConstant(params))) override fun toProto(userDataReader: UserDataReader): Value = expr.toProto(userDataReader) @@ -7810,7 +7807,7 @@ class Ordering internal constructor(val expr: Expression, val dir: Direction) { .build() } -internal class Variable(val name: String) : Expression() { +private class Variable(val name: String) : Expression() { override fun toProto(userDataReader: UserDataReader): Value = Value.newBuilder().setVariableReferenceValue(name).build() override fun evaluateFunction(context: EvaluationContext): EvaluateDocument = diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/stage.kt b/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/stage.kt index 353936d87b8..2c9430d643c 100644 --- a/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/stage.kt +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/stage.kt @@ -407,7 +407,8 @@ internal constructor( documents.asSequence().map(::encodeValue) } -internal class SubcollectionSource +@Beta +class SubcollectionSource private constructor(internal val path: String, options: InternalOptions = InternalOptions.EMPTY) : Stage("subcollection", options) { companion object { @@ -417,7 +418,7 @@ private constructor(internal val path: String, options: InternalOptions = Intern * @param path The path of the subcollection that will be the source of this pipeline. */ @JvmStatic - internal fun of(path: String): SubcollectionSource { + fun of(path: String): SubcollectionSource { return SubcollectionSource(path) } } @@ -1369,7 +1370,7 @@ internal constructor( override fun self(options: InternalOptions) = DefineStage(aliasedExpressions, options) override fun canonicalId(): String { - TODO("Not yet implemented") + return "${name}(${aliasedExpressions.joinToString(",") { "${it.alias}=${it.expr.canonicalId()}" }})" } override fun args(userDataReader: UserDataReader): Sequence { diff --git a/firebase-firestore/src/test/java/com/google/firebase/firestore/pipeline/SubqueryPipelineTests.kt b/firebase-firestore/src/test/java/com/google/firebase/firestore/pipeline/SubqueryPipelineTests.kt deleted file mode 100644 index 48c21166768..00000000000 --- a/firebase-firestore/src/test/java/com/google/firebase/firestore/pipeline/SubqueryPipelineTests.kt +++ /dev/null @@ -1,122 +0,0 @@ -// Copyright 2025 Google LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package com.google.firebase.firestore.pipeline - -import com.google.common.truth.Truth.assertThat -import com.google.firebase.firestore.Pipeline -import com.google.firebase.firestore.TestUtil -import com.google.firebase.firestore.pipeline.Expression.Companion.currentDocument -import com.google.firebase.firestore.pipeline.Expression.Companion.field -import com.google.firebase.firestore.pipeline.Expression.Companion.variable -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner - -@RunWith(RobolectricTestRunner::class) -internal class SubqueryPipelineTests { - - private val db = TestUtil.firestore() - private val userDataReader = TestUtil.USER_DATA_READER - - @Test - fun `define creates DefineStage in proto`() { - // Manually construct Pipeline or use a helper - // Since Pipeline constructor is internal, we can access it from this internal class in the same - // module - val pipeline = Pipeline(db, userDataReader, emptyList()).define(field("title").alias("t")) - - val proto = pipeline.toPipelineProto(userDataReader) - assertThat(proto.stagesCount).isEqualTo(1) - val stage = proto.getStages(0) - assertThat(stage.name).isEqualTo("let") - // Verify args or options contains the variable - // DefineStage puts variables in args as map - assertThat(stage.argsCount).isEqualTo(1) - val mapValue = stage.getArgs(0).mapValue - assertThat(mapValue).isNotNull() - // Verify variable mapping - } - - @Test - fun `subcollection creates pipeline with SubcollectionSource`() { - val pipeline = Pipeline.subcollection("reviews") - // We must provide a reader override since "subcollection" uses null internally - val proto = pipeline.toPipelineProto(userDataReader) - - assertThat(proto.stagesCount).isEqualTo(1) - val stage = proto.getStages(0) - assertThat(stage.name).isEqualTo("subcollection") - val pathArg = stage.getArgs(0) - // args(0) is path string encoded? - // SubcollectionSource args: sequenceOf(encodeValue(path)) - assertThat(pathArg.stringValue).isEqualTo("reviews") - } - - @Test - fun `toArrayExpression creates FunctionExpression`() { - val subPipeline = Pipeline.subcollection("sub_items") - val expr = subPipeline.toArrayExpression() - - val protoValue = expr.toProto(userDataReader) - assertThat(protoValue.hasFunctionValue()).isTrue() - assertThat(protoValue.functionValue.name).isEqualTo("array") - assertThat(protoValue.functionValue.argsCount).isEqualTo(1) - val pipelineArg = protoValue.functionValue.getArgs(0) - assertThat(pipelineArg.hasPipelineValue()).isTrue() - } - - @Test - fun `toScalarExpression creates FunctionExpression`() { - val subPipeline = Pipeline.subcollection("sub_items") - val expr = subPipeline.toScalarExpression() - - val protoValue = expr.toProto(userDataReader) - assertThat(protoValue.hasFunctionValue()).isTrue() - assertThat(protoValue.functionValue.name).isEqualTo("scalar") - assertThat(protoValue.functionValue.argsCount).isEqualTo(1) - val pipelineArg = protoValue.functionValue.getArgs(0) - assertThat(pipelineArg.hasPipelineValue()).isTrue() - } - - @Test - fun `variable expression proto`() { - val v = variable("my_var") - val proto = v.toProto(userDataReader) - assertThat(proto.variableReferenceValue).isEqualTo("my_var") - } - - @Test - fun `currentDocument expression proto`() { - val cd = currentDocument() - val proto = cd.toProto(userDataReader) - assertThat(proto.hasFunctionValue()).isTrue() - assertThat(proto.functionValue.name).isEqualTo("current_document") - assertThat(proto.functionValue.argsCount).isEqualTo(0) - } - - @Test - fun `Expression getField creates field`() { - val v = variable("my_var") - val f2 = Expression.getField(v, "sub") - - val proto2 = f2.toProto(userDataReader) - - assertThat(proto2.hasFunctionValue()).isTrue() - assertThat(proto2.functionValue.name).isEqualTo("field") - assertThat(proto2.functionValue.argsCount).isEqualTo(2) - assertThat(proto2.functionValue.getArgs(0).variableReferenceValue).isEqualTo("my_var") - assertThat(proto2.functionValue.getArgs(1).stringValue).isEqualTo("sub") - } -} From 59ce95078fc6959307e8574924f80f8cd38fb98b Mon Sep 17 00:00:00 2001 From: cherylEnkidu Date: Tue, 3 Mar 2026 16:13:23 -0500 Subject: [PATCH 31/53] Fix error message in test testDirectExecutionOfSubcollectionPipeline --- .../com/google/firebase/firestore/SubqueryIntegrationTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/SubqueryIntegrationTest.kt b/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/SubqueryIntegrationTest.kt index bd04ec8fc8e..d007cf13ee1 100644 --- a/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/SubqueryIntegrationTest.kt +++ b/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/SubqueryIntegrationTest.kt @@ -1029,6 +1029,6 @@ class SubqueryIntegrationTest { sub.execute() } - assertThat(exception.message).contains("Cannot execute pipeline without a Firestore instance") + assertThat(exception.message).contains("Cannot execute a relative subcollection pipeline directly") } } From b51677910b1b306cde5e90ee55a4f7c2e6ee24e3 Mon Sep 17 00:00:00 2001 From: cherylEnkidu Date: Wed, 4 Mar 2026 13:36:30 -0500 Subject: [PATCH 32/53] merge main and format code --- .../com/google/firebase/firestore/SubqueryIntegrationTest.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/SubqueryIntegrationTest.kt b/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/SubqueryIntegrationTest.kt index d007cf13ee1..d3134c6c591 100644 --- a/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/SubqueryIntegrationTest.kt +++ b/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/SubqueryIntegrationTest.kt @@ -1029,6 +1029,7 @@ class SubqueryIntegrationTest { sub.execute() } - assertThat(exception.message).contains("Cannot execute a relative subcollection pipeline directly") + assertThat(exception.message) + .contains("Cannot execute a relative subcollection pipeline directly") } } From 64c293a22c80c7f1ef6f301346327233f335698c Mon Sep 17 00:00:00 2001 From: cherylEnkidu Date: Thu, 5 Mar 2026 13:59:33 -0500 Subject: [PATCH 33/53] replace mapGet with getField, add missing test check --- .../google/firebase/firestore/SubqueryIntegrationTest.kt | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/SubqueryIntegrationTest.kt b/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/SubqueryIntegrationTest.kt index d3134c6c591..4a971c47d94 100644 --- a/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/SubqueryIntegrationTest.kt +++ b/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/SubqueryIntegrationTest.kt @@ -718,7 +718,7 @@ class SubqueryIntegrationTest { db .pipeline() .collection(reviewsCollName) - .where(equal("authorName", variable("doc").mapGet("author"))) + .where(equal("authorName", variable("doc").getField("author"))) .select(field("reviewer").alias("reviewer")) val results = @@ -809,7 +809,7 @@ class SubqueryIntegrationTest { db .pipeline() .collection(reviewsCollName) - .where(equal("bookId", variable("doc").mapGet("does_not_exist"))) + .where(equal("bookId", variable("doc").getField("does_not_exist"))) .select(field("reviewer").alias("reviewer")) val results = @@ -823,6 +823,9 @@ class SubqueryIntegrationTest { .select("title", "reviews") .execute() ) + + assertThat(results.map { it.getData() }) + .containsExactly(mapOf("title" to "1984", "reviews" to emptyList())) } @Test From ae3fb442df7e54714d1f7876b39333162e872a91 Mon Sep 17 00:00:00 2001 From: cherylEnkidu Date: Tue, 10 Mar 2026 14:01:10 -0400 Subject: [PATCH 34/53] move subcollection from Pipeline class to PipelineSource class --- .../firestore/SubqueryIntegrationTest.kt | 8 +- .../com/google/firebase/firestore/Pipeline.kt | 98 +++++++++---------- 2 files changed, 54 insertions(+), 52 deletions(-) diff --git a/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/SubqueryIntegrationTest.kt b/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/SubqueryIntegrationTest.kt index 4a971c47d94..46b73d3312e 100644 --- a/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/SubqueryIntegrationTest.kt +++ b/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/SubqueryIntegrationTest.kt @@ -978,7 +978,8 @@ class SubqueryIntegrationTest { .set(mapOf("reviewer" to "Alice")) ) - val reviewsSub = Pipeline.subcollection("reviews").select(field("reviewer").alias("reviewer")) + val reviewsSub = + PipelineSource.subcollection("reviews").select(field("reviewer").alias("reviewer")) val results = waitFor( @@ -1004,7 +1005,8 @@ class SubqueryIntegrationTest { // Notably NO subcollections are added to doc1 - val missingSub = Pipeline.subcollection("does_not_exist").select(variable("p").alias("sub_p")) + val missingSub = + PipelineSource.subcollection("does_not_exist").select(variable("p").alias("sub_p")) val results = waitFor( @@ -1024,7 +1026,7 @@ class SubqueryIntegrationTest { @Test fun testDirectExecutionOfSubcollectionPipeline() { - val sub = Pipeline.subcollection("reviews") + val sub = PipelineSource.subcollection("reviews") val exception = org.junit.Assert.assertThrows(IllegalStateException::class.java) { diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/Pipeline.kt b/firebase-firestore/src/main/java/com/google/firebase/firestore/Pipeline.kt index aab57f9e72f..41382da61b7 100644 --- a/firebase-firestore/src/main/java/com/google/firebase/firestore/Pipeline.kt +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/Pipeline.kt @@ -1102,55 +1102,6 @@ internal constructor( fun toScalarExpression(): Expression { return FunctionExpression("scalar", notImplemented, Expression.toExprOrConstant(this)) } - - companion object { - /** - * Initializes a pipeline scoped to a subcollection. - * - * This method allows you to start a new pipeline that operates on a subcollection of the - * current document. It is intended to be used as a subquery. - * - * **Note:** A pipeline created with `subcollection` cannot be executed directly. It must be - * used within a parent pipeline. - * - * Example: - * ``` - * firestore.pipeline().collection("books") - * .addFields( - * Pipeline.subcollection("reviews") - * .aggregate(AggregateFunction.average("rating").as("avg_rating")) - * .toScalarExpression().as("average_rating")); - * ``` - * - * @param path The path of the subcollection. - * @return A new [Pipeline] instance scoped to the subcollection. - */ - @JvmStatic - fun subcollection(path: String): Pipeline { - return Pipeline(null, null, SubcollectionSource.of(path)) - } - - /** - * Creates a pipeline that processes the documents in the specified subcollection of the current - * document. - * - * Example: - * ``` - * firestore.pipeline().collection("books") - * .addFields( - * Pipeline.subcollection(SubcollectionSource.of("reviews")) - * .aggregate(AggregateFunction.average("rating").as("avg_rating")) - * .toScalarExpression().as("average_rating")); - * ``` - * - * @param source The subcollection that will be the source of this pipeline. - * @return A new [Pipeline] scoped to the subcollection. - */ - @JvmStatic - fun subcollection(source: SubcollectionSource): Pipeline { - return Pipeline(null, null, source) - } - } } /** Start of a Firestore Pipeline */ @@ -1299,6 +1250,55 @@ class PipelineSource internal constructor(private val firestore: FirebaseFiresto DocumentsSource(documents.map { ResourcePath.fromString(it.path) }.toTypedArray()) ) } + + companion object { + /** + * Initializes a pipeline scoped to a subcollection. + * + * This method allows you to start a new pipeline that operates on a subcollection of the + * current document. It is intended to be used as a subquery. + * + * **Note:** A pipeline created with `subcollection` cannot be executed directly. It must be + * used within a parent pipeline. + * + * Example: + * ``` + * firestore.pipeline().collection("books") + * .addFields( + * PipelineSource.subcollection("reviews") + * .aggregate(AggregateFunction.average("rating").as("avg_rating")) + * .toScalarExpression().as("average_rating")); + * ``` + * + * @param path The path of the subcollection. + * @return A new [Pipeline] instance scoped to the subcollection. + */ + @JvmStatic + fun subcollection(path: String): Pipeline { + return Pipeline(null, null, SubcollectionSource.of(path)) + } + + /** + * Creates a pipeline that processes the documents in the specified subcollection of the current + * document. + * + * Example: + * ``` + * firestore.pipeline().collection("books") + * .addFields( + * PipelineSource.subcollection(SubcollectionSource.of("reviews")) + * .aggregate(AggregateFunction.average("rating").as("avg_rating")) + * .toScalarExpression().as("average_rating")); + * ``` + * + * @param source The subcollection that will be the source of this pipeline. + * @return A new [Pipeline] scoped to the subcollection. + */ + @JvmStatic + fun subcollection(source: SubcollectionSource): Pipeline { + return Pipeline(null, null, source) + } + } } /** From 91ce6576ad989e14a16d7d00c6412a832ad8a9ce Mon Sep 17 00:00:00 2001 From: cherylEnkidu Date: Tue, 10 Mar 2026 16:14:50 -0400 Subject: [PATCH 35/53] enable subcollection tests --- .../google/firebase/firestore/SubqueryIntegrationTest.kt | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/SubqueryIntegrationTest.kt b/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/SubqueryIntegrationTest.kt index 46b73d3312e..7f561ea481c 100644 --- a/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/SubqueryIntegrationTest.kt +++ b/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/SubqueryIntegrationTest.kt @@ -1,5 +1,5 @@ /* - * Copyright 2024 Google LLC + * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -30,7 +30,6 @@ import com.google.firebase.firestore.testutil.IntegrationTestUtil.waitFor import com.google.firebase.firestore.util.Util.autoId import org.junit.After import org.junit.Before -import org.junit.Ignore import org.junit.Test import org.junit.runner.RunWith @@ -962,7 +961,6 @@ class SubqueryIntegrationTest { assertThat(results).isNotEmpty() } - @Ignore("Pending backend support") @Test fun testStandardSubcollectionQuery() { val collName = "subcoll_test_" + autoId() @@ -993,10 +991,9 @@ class SubqueryIntegrationTest { ) assertThat(results.map { it.getData() }) - .containsExactly(mapOf("title" to "1984", "reviews" to listOf(mapOf("reviewer" to "Alice")))) + .containsExactly(mapOf("title" to "1984", "reviews" to listOf("Alice"))) } - @Ignore("Pending backend support") @Test fun testMissingSubcollection() { val collName = "subcoll_missing_" + autoId() @@ -1013,7 +1010,7 @@ class SubqueryIntegrationTest { db .pipeline() .collection(collName) - .define(variable("parentDoc").alias("p")) + .define(currentDocument().alias("p")) .select(missingSub.toArrayExpression().alias("missing_data")) .limit(1) .execute() From 5a0a535d461f3eedf164eb4abcbe0b27a699a6e5 Mon Sep 17 00:00:00 2001 From: cherylEnkidu Date: Tue, 10 Mar 2026 16:23:25 -0400 Subject: [PATCH 36/53] remove define stage overload --- firebase-firestore/api.txt | 10 +----- .../com/google/firebase/firestore/Pipeline.kt | 32 ------------------- .../firebase/firestore/pipeline/stage.kt | 3 +- 3 files changed, 2 insertions(+), 43 deletions(-) diff --git a/firebase-firestore/api.txt b/firebase-firestore/api.txt index 4233fe64807..139ad5cc56e 100644 --- a/firebase-firestore/api.txt +++ b/firebase-firestore/api.txt @@ -425,7 +425,7 @@ package com.google.firebase.firestore { method public com.google.firebase.firestore.Pipeline aggregate(com.google.firebase.firestore.pipeline.AggregateStage aggregateStage, com.google.firebase.firestore.pipeline.AggregateOptions options); method public com.google.firebase.firestore.Pipeline aggregate(com.google.firebase.firestore.pipeline.AliasedAggregate accumulator, com.google.firebase.firestore.pipeline.AliasedAggregate... additionalAccumulators); method public com.google.firebase.firestore.Pipeline define(com.google.firebase.firestore.pipeline.AliasedExpression aliasedExpression, com.google.firebase.firestore.pipeline.AliasedExpression... additionalExpressions); - method public com.google.firebase.firestore.Pipeline define(com.google.firebase.firestore.pipeline.DefineStage stage); + method public com.google.firebase.firestore.Pipeline distinct(com.google.firebase.firestore.pipeline.Selectable group, java.lang.Object... additionalGroups); method public com.google.firebase.firestore.Pipeline distinct(String groupField, java.lang.Object... additionalGroups); method public com.google.android.gms.tasks.Task execute(); @@ -792,14 +792,6 @@ package com.google.firebase.firestore.pipeline { method public com.google.firebase.firestore.pipeline.CollectionSourceOptions withHints(com.google.firebase.firestore.pipeline.CollectionHints hints); } - @com.google.common.annotations.Beta public final class DefineStage extends com.google.firebase.firestore.pipeline.Stage { - method public static com.google.firebase.firestore.pipeline.DefineStage withVariables(com.google.firebase.firestore.pipeline.AliasedExpression aliasedExpression, com.google.firebase.firestore.pipeline.AliasedExpression... additionalExpressions); - field public static final com.google.firebase.firestore.pipeline.DefineStage.Companion Companion; - } - - public static final class DefineStage.Companion { - method public com.google.firebase.firestore.pipeline.DefineStage withVariables(com.google.firebase.firestore.pipeline.AliasedExpression aliasedExpression, com.google.firebase.firestore.pipeline.AliasedExpression... additionalExpressions); - } @com.google.common.annotations.Beta public abstract class Expression { method public final com.google.firebase.firestore.pipeline.Expression abs(); diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/Pipeline.kt b/firebase-firestore/src/main/java/com/google/firebase/firestore/Pipeline.kt index 41382da61b7..624f29f1eee 100644 --- a/firebase-firestore/src/main/java/com/google/firebase/firestore/Pipeline.kt +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/Pipeline.kt @@ -942,38 +942,6 @@ internal constructor( return append(DefineStage(arrayOf(aliasedExpression, *additionalExpressions))) } - /** - * Defines one or more variables in the pipeline's scope using a [DefineStage] object. - * - * This stage allows you to bind a value to a variable for internal reuse within the pipeline body - * (accessed via the `variable()` function). It is useful for declaring reusable values or - * intermediate calculations that can be referenced multiple times in later parts of the pipeline, - * improving readability and maintainability. - * - * You can specify: - * - * - **Variables:** One or more variables using [AliasedExpression] which pairs an expression with - * a name (alias). The expression can be a simple constant, a field reference, or a complex - * computation. - * - * Example: - * ``` - * firestore.pipeline().collection("products") - * .define( - * DefineStage.withVariables( - * multiply(field("price"), 0.9).as("discountedPrice"), - * add(field("stock"), 10).as("newStock") - * ) - * ) - * .where(lessThan(variable("discountedPrice"), 100)) - * .select(field("name"), variable("newStock")); - * ``` - * - * @param stage A [DefineStage] object that specifies the variables to define. - * @return A new [Pipeline] object with this stage appended to the stage list. - */ - fun define(stage: DefineStage): Pipeline = append(stage) - /** * Converts this Pipeline into an expression that evaluates to an array of results. * diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/stage.kt b/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/stage.kt index 2c9430d643c..5ec271be245 100644 --- a/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/stage.kt +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/stage.kt @@ -1350,8 +1350,7 @@ class UnnestOptions private constructor(options: InternalOptions) : } } -@Beta -class DefineStage +internal class DefineStage internal constructor( internal val aliasedExpressions: Array, options: InternalOptions = InternalOptions.EMPTY From f9cb9f99a362ad2e5715a49c7b298d6d38a4bcb8 Mon Sep 17 00:00:00 2001 From: cherylEnkidu Date: Tue, 17 Mar 2026 15:22:13 -0400 Subject: [PATCH 37/53] Add variable reference --- .../src/proto/google/firestore/v1/document.proto | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/firebase-firestore/src/proto/google/firestore/v1/document.proto b/firebase-firestore/src/proto/google/firestore/v1/document.proto index 9947a289a1e..125e8619796 100644 --- a/firebase-firestore/src/proto/google/firestore/v1/document.proto +++ b/firebase-firestore/src/proto/google/firestore/v1/document.proto @@ -146,6 +146,14 @@ message Value { // allowed to be used on the write path. --) string field_reference_value = 19; + // Pointer to a variable defined elsewhere in a pipeline. + // + // Unlike `field_reference_value` which references a field within a + // document, this refers to a variable, defined in a separate namespace than + // the fields of a document. + // + string variable_reference_value = 22; + // A value that represents an unevaluated expression. // // **Requires:** From ca03ba54a80f50a6ac83f7a69552f5c4f8d71837 Mon Sep 17 00:00:00 2001 From: cherylEnkidu Date: Tue, 17 Mar 2026 15:36:52 -0400 Subject: [PATCH 38/53] improve error message --- .../com/google/firebase/firestore/SubqueryIntegrationTest.kt | 4 +++- .../src/main/java/com/google/firebase/firestore/Pipeline.kt | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/SubqueryIntegrationTest.kt b/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/SubqueryIntegrationTest.kt index 7f561ea481c..acb3e26f8e6 100644 --- a/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/SubqueryIntegrationTest.kt +++ b/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/SubqueryIntegrationTest.kt @@ -1032,6 +1032,8 @@ class SubqueryIntegrationTest { } assertThat(exception.message) - .contains("Cannot execute a relative subcollection pipeline directly") + .contains( + "This pipeline was created without a database (e.g., as a subcollection pipeline) and cannot be executed directly. It can only be used as part of another pipeline." + ) } } diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/Pipeline.kt b/firebase-firestore/src/main/java/com/google/firebase/firestore/Pipeline.kt index 624f29f1eee..a65963a46d9 100644 --- a/firebase-firestore/src/main/java/com/google/firebase/firestore/Pipeline.kt +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/Pipeline.kt @@ -162,7 +162,9 @@ internal constructor( } private fun toExecutePipelineRequest(options: InternalOptions?): ExecutePipelineRequest { - checkNotNull(firestore) { "Cannot execute a relative subcollection pipeline directly" } + checkNotNull(firestore) { + "This pipeline was created without a database (e.g., as a subcollection pipeline) and cannot be executed directly. It can only be used as part of another pipeline." + } val database = firestore!!.databaseId val builder = ExecutePipelineRequest.newBuilder() builder.database = "projects/${database.projectId}/databases/${database.databaseId}" From 3a898c09d24e42b793cfae410d0cf791d3e0ae88 Mon Sep 17 00:00:00 2001 From: cherylEnkidu Date: Tue, 17 Mar 2026 16:05:15 -0400 Subject: [PATCH 39/53] improve readability in test --- .../firestore/SubqueryIntegrationTest.kt | 29 ++++++------------- 1 file changed, 9 insertions(+), 20 deletions(-) diff --git a/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/SubqueryIntegrationTest.kt b/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/SubqueryIntegrationTest.kt index acb3e26f8e6..05585e7c19c 100644 --- a/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/SubqueryIntegrationTest.kt +++ b/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/SubqueryIntegrationTest.kt @@ -24,6 +24,9 @@ import com.google.firebase.firestore.pipeline.Expression.Companion.currentDocume import com.google.firebase.firestore.pipeline.Expression.Companion.equal import com.google.firebase.firestore.pipeline.Expression.Companion.field import com.google.firebase.firestore.pipeline.Expression.Companion.or +import com.google.firebase.firestore.pipeline.AggregateFunction.Companion.average +import com.google.firebase.firestore.pipeline.AggregateFunction.Companion.countAll +import com.google.firebase.firestore.pipeline.AggregateFunction.Companion.sum import com.google.firebase.firestore.pipeline.Expression.Companion.variable import com.google.firebase.firestore.testutil.IntegrationTestUtil import com.google.firebase.firestore.testutil.IntegrationTestUtil.waitFor @@ -411,9 +414,7 @@ class SubqueryIntegrationTest { .pipeline() .collection(reviewsCollName) .where(equal("bookTitle", variable("book_title"))) - .aggregate( - com.google.firebase.firestore.pipeline.AggregateFunction.average("rating").alias("val") - ) + .aggregate(average("rating").alias("val")) val results = waitFor( @@ -444,10 +445,7 @@ class SubqueryIntegrationTest { .pipeline() .collection(reviewsCollName) .where(equal("bookTitle", variable("book_title"))) - .aggregate( - com.google.firebase.firestore.pipeline.AggregateFunction.average("rating").alias("avg"), - com.google.firebase.firestore.pipeline.AggregateFunction.countAll().alias("count") - ) + .aggregate(average("rating").alias("avg"), countAll().alias("count")) val results = waitFor( @@ -476,9 +474,7 @@ class SubqueryIntegrationTest { .pipeline() .collection(reviewsCollName) .where(equal("bookTitle", variable("book_title"))) - .aggregate( - com.google.firebase.firestore.pipeline.AggregateFunction.average("rating").alias("avg") - ) + .aggregate(average("rating").alias("avg")) val results = waitFor( @@ -557,9 +553,7 @@ class SubqueryIntegrationTest { .pipeline() .collection(reviewsCollName) .where(equal("bookTitle", variable("book_title"))) - .aggregate( - com.google.firebase.firestore.pipeline.AggregateFunction.average("rating").alias("val") - ) + .aggregate(average("rating").alias("val")) val results = waitFor( @@ -916,9 +910,7 @@ class SubqueryIntegrationTest { .pipeline() .collection(innerColl) .where(equal("outer_id", variable("oid"))) - .aggregate( - com.google.firebase.firestore.pipeline.AggregateFunction.average("score").alias("s") - ) + .aggregate(average("score").alias("s")) val results = waitFor( @@ -928,10 +920,7 @@ class SubqueryIntegrationTest { .define(field("id").alias("oid")) .addFields(innerSub.toScalarExpression().alias("doc_score")) // Now we aggregate over the calculated subquery results - .aggregate( - com.google.firebase.firestore.pipeline.AggregateFunction.sum("doc_score") - .alias("total_score") - ) + .aggregate(sum("doc_score").alias("total_score")) .execute() ) From fc04006198a61ab924d452a8ab5c7be8962e5421 Mon Sep 17 00:00:00 2001 From: cherylEnkidu Date: Wed, 18 Mar 2026 15:09:15 -0400 Subject: [PATCH 40/53] address feedbacks --- .../firestore/SubqueryIntegrationTest.kt | 6 ++--- .../com/google/firebase/firestore/Pipeline.kt | 12 ++++----- .../firestore/pipeline/expressions.kt | 26 ++++++++++--------- 3 files changed, 23 insertions(+), 21 deletions(-) diff --git a/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/SubqueryIntegrationTest.kt b/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/SubqueryIntegrationTest.kt index 05585e7c19c..4e46157cb78 100644 --- a/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/SubqueryIntegrationTest.kt +++ b/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/SubqueryIntegrationTest.kt @@ -18,15 +18,15 @@ package com.google.firebase.firestore import androidx.test.ext.junit.runners.AndroidJUnit4 import com.google.common.truth.Truth.assertThat +import com.google.firebase.firestore.pipeline.AggregateFunction.Companion.average +import com.google.firebase.firestore.pipeline.AggregateFunction.Companion.countAll +import com.google.firebase.firestore.pipeline.AggregateFunction.Companion.sum import com.google.firebase.firestore.pipeline.Expression.Companion.and import com.google.firebase.firestore.pipeline.Expression.Companion.constant import com.google.firebase.firestore.pipeline.Expression.Companion.currentDocument import com.google.firebase.firestore.pipeline.Expression.Companion.equal import com.google.firebase.firestore.pipeline.Expression.Companion.field import com.google.firebase.firestore.pipeline.Expression.Companion.or -import com.google.firebase.firestore.pipeline.AggregateFunction.Companion.average -import com.google.firebase.firestore.pipeline.AggregateFunction.Companion.countAll -import com.google.firebase.firestore.pipeline.AggregateFunction.Companion.sum import com.google.firebase.firestore.pipeline.Expression.Companion.variable import com.google.firebase.firestore.testutil.IntegrationTestUtil import com.google.firebase.firestore.testutil.IntegrationTestUtil.waitFor diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/Pipeline.kt b/firebase-firestore/src/main/java/com/google/firebase/firestore/Pipeline.kt index a65963a46d9..3df2c294744 100644 --- a/firebase-firestore/src/main/java/com/google/firebase/firestore/Pipeline.kt +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/Pipeline.kt @@ -165,7 +165,7 @@ internal constructor( checkNotNull(firestore) { "This pipeline was created without a database (e.g., as a subcollection pipeline) and cannot be executed directly. It can only be used as part of another pipeline." } - val database = firestore!!.databaseId + val database = firestore.databaseId val builder = ExecutePipelineRequest.newBuilder() builder.database = "projects/${database.projectId}/databases/${database.databaseId}" builder.structuredPipeline = toStructuredPipelineProto(options, firestore.userDataReader) @@ -915,14 +915,14 @@ internal constructor( * variable for internal reuse within the pipeline body (accessed via the `variable()` function). * * This stage is useful for declaring reusable values or intermediate calculations that can be - * referenced multiple times in later parts of the pipeline, improving readability and - * maintainability. + * referenced multiple times in later parts of the pipeline. * * Each variable is defined using an [AliasedExpression], which pairs an expression with a name - * (alias). The expression can be a simple constant, a field reference, or a complex computation. + * (alias). The expression can be a simple constant, a field reference, or a function evaluation + * (such as a mathematical operation). * * Example: - * ``` + * ```kotlin * firestore.pipeline().collection("products") * .define( * multiply(field("price"), 0.9).as("discountedPrice"), @@ -1140,7 +1140,7 @@ class PipelineSource internal constructor(private val firestore: FirebaseFiresto fun collection(ref: CollectionReference, options: CollectionSourceOptions): Pipeline { if ( ref.firestore.databaseId != firestore.databaseId || - ref.firestore.app?.options?.projectId != firestore.app?.options?.projectId + ref.firestore.app.options.projectId != firestore.app.options.projectId ) { throw IllegalArgumentException( "Invalid CollectionReference. The Firestore instance of the CollectionReference must match the Firestore instance of the PipelineSource." diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/expressions.kt b/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/expressions.kt index 87e456b4ce9..469eed6ec08 100644 --- a/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/expressions.kt +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/expressions.kt @@ -3076,8 +3076,7 @@ abstract class Expression internal constructor() { map(elements.flatMap { listOf(constant(it.key), toExprOrConstant(it.value)) }.toTypedArray()) /** - * Accesses a field/property of the expression (When the expression evaluates to a Map or - * Document). + * Accesses a field/property of a document or Map using the provided [key]. * * @param expression The expression evaluating to a map or document. * @param key The key of the field to access. @@ -3099,7 +3098,7 @@ abstract class Expression internal constructor() { FunctionExpression("field", notImplemented, fieldName, key) /** - * Accesses a field/property of the expression using the provided [keyExpression]. + * Accesses a field/property of a document or Map using the provided [keyExpression]. * * @param expression The expression evaluating to a Map or Document. * @param keyExpression The expression evaluating to the key. @@ -5988,11 +5987,14 @@ abstract class Expression internal constructor() { * Creates an expression that retrieves the value of a variable bound via [Pipeline.define]. * * Example: - * ``` - * // Define a variable "discountedPrice" and use it in a filter + * ```kotlin * firestore.pipeline().collection("products") - * .define(Constant(100).alias("threshold")) - * .where(lessThan(variable("discountedPrice"), variable("threshold"))); + * .define( + * multiply(field("price"), 0.9).as("discountedPrice"), + * add(field("stock"), 10).as("newStock") + * ) + * .where(lessThan(variable("discountedPrice"), 100)) + * .select(field("name"), variable("newStock")); * ``` * * @param name The name of the variable to retrieve. @@ -7152,18 +7154,18 @@ abstract class Expression internal constructor() { Companion.mapMerge(this, mapExpr, *otherMaps) /** - * Retrieves the value of a specific field from the document evaluated by this expression. + * Accesses a field/property of a document or Map using the provided [key]. * * @param key The string key to access. - * @return A new [Expression] representing the field value. + * @return A new [Expression] representing the value of the field. */ fun getField(key: String): Expression = Companion.getField(this, key) /** - * Retrieves the value of a specific field from the document evaluated by this expression. + * Accesses a field/property of a document or Map using the provided [keyExpression]. * * @param keyExpression The expression evaluating to the key to access. - * @return A new [Expression] representing the field value. + * @return A new [Expression] representing the value of the field. */ fun getField(keyExpression: Expression): Expression = Companion.getField(this, keyExpression) @@ -8376,7 +8378,7 @@ class Field internal constructor(internal val fieldPath: ModelFieldPath) : Selec EvaluateResult.timestamp(getLocalWriteTime(fieldValue)) DocumentSnapshot.ServerTimestampBehavior.PREVIOUS -> { val previousValue = getPreviousValue(fieldValue) - if (previousValue == null) EvaluateResult.NULL else EvaluateResultValue(previousValue!!) + if (previousValue == null) EvaluateResult.NULL else EvaluateResultValue(previousValue) } } } From 6f3b0526422955c207eba20831df2d25822abaaa Mon Sep 17 00:00:00 2001 From: cherylEnkidu Date: Wed, 18 Mar 2026 15:44:01 -0400 Subject: [PATCH 41/53] add kotlin annotation --- .../src/main/java/com/google/firebase/firestore/Pipeline.kt | 4 ++-- .../com/google/firebase/firestore/pipeline/expressions.kt | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/Pipeline.kt b/firebase-firestore/src/main/java/com/google/firebase/firestore/Pipeline.kt index 3df2c294744..b546222db65 100644 --- a/firebase-firestore/src/main/java/com/google/firebase/firestore/Pipeline.kt +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/Pipeline.kt @@ -1232,7 +1232,7 @@ class PipelineSource internal constructor(private val firestore: FirebaseFiresto * used within a parent pipeline. * * Example: - * ``` + * ```kotlin * firestore.pipeline().collection("books") * .addFields( * PipelineSource.subcollection("reviews") @@ -1253,7 +1253,7 @@ class PipelineSource internal constructor(private val firestore: FirebaseFiresto * document. * * Example: - * ``` + * ```kotlin * firestore.pipeline().collection("books") * .addFields( * PipelineSource.subcollection(SubcollectionSource.of("reviews")) diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/expressions.kt b/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/expressions.kt index 469eed6ec08..87fb45cac54 100644 --- a/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/expressions.kt +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/expressions.kt @@ -6006,7 +6006,7 @@ abstract class Expression internal constructor() { * Creates an expression that represents the current document being processed. * * Example: - * ``` + * ```kotlin * // Define the current document as a variable "doc" * firestore.pipeline().collection("books") * .define(currentDocument().alias("doc")) From 322b7a3b402da263fceea6fec99fb51de808d13e Mon Sep 17 00:00:00 2001 From: cherylEnkidu Date: Wed, 18 Mar 2026 16:37:18 -0400 Subject: [PATCH 42/53] revert unnecessary changes --- .../src/main/java/com/google/firebase/firestore/Pipeline.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/Pipeline.kt b/firebase-firestore/src/main/java/com/google/firebase/firestore/Pipeline.kt index b546222db65..5595dcdf13a 100644 --- a/firebase-firestore/src/main/java/com/google/firebase/firestore/Pipeline.kt +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/Pipeline.kt @@ -1140,7 +1140,7 @@ class PipelineSource internal constructor(private val firestore: FirebaseFiresto fun collection(ref: CollectionReference, options: CollectionSourceOptions): Pipeline { if ( ref.firestore.databaseId != firestore.databaseId || - ref.firestore.app.options.projectId != firestore.app.options.projectId + ref.firestore.app?.options?.projectId != firestore.app?.options?.projectId ) { throw IllegalArgumentException( "Invalid CollectionReference. The Firestore instance of the CollectionReference must match the Firestore instance of the PipelineSource." From 9a64aaa8424cdfa9c3c54f96aa2e0f1d1e093005 Mon Sep 17 00:00:00 2001 From: Mila <107142260+milaGGL@users.noreply.github.com> Date: Thu, 19 Mar 2026 10:39:40 -0400 Subject: [PATCH 43/53] feat(firestore): Add arraySlice and arrayFilter expressions --- .../firebase/firestore/PipelineTest.java | 76 ++++++++++ .../firestore/pipeline/expressions.kt | 142 ++++++++++++++++++ 2 files changed, 218 insertions(+) diff --git a/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/PipelineTest.java b/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/PipelineTest.java index 7c0c19cb18e..a0853ee4b1a 100644 --- a/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/PipelineTest.java +++ b/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/PipelineTest.java @@ -20,6 +20,7 @@ import static com.google.firebase.firestore.pipeline.Expression.array; import static com.google.firebase.firestore.pipeline.Expression.arrayContains; import static com.google.firebase.firestore.pipeline.Expression.arrayContainsAny; +import static com.google.firebase.firestore.pipeline.Expression.arrayFilter; import static com.google.firebase.firestore.pipeline.Expression.arrayFirst; import static com.google.firebase.firestore.pipeline.Expression.arrayFirstN; import static com.google.firebase.firestore.pipeline.Expression.arrayIndexOf; @@ -31,6 +32,7 @@ import static com.google.firebase.firestore.pipeline.Expression.arrayMaximumN; import static com.google.firebase.firestore.pipeline.Expression.arrayMinimum; import static com.google.firebase.firestore.pipeline.Expression.arrayMinimumN; +import static com.google.firebase.firestore.pipeline.Expression.arraySlice; import static com.google.firebase.firestore.pipeline.Expression.collectionId; import static com.google.firebase.firestore.pipeline.Expression.concat; import static com.google.firebase.firestore.pipeline.Expression.constant; @@ -856,6 +858,80 @@ public void arrayMaximumNWorks() { ImmutableList.of("magic", "epic", "adventure"))); } +@Test + public void arrayFilterWorks() { + Task execute = firestore + .pipeline() + .collection(randomCol) + .where(equal("title", "The Lord of the Rings")) + .select( + field("tags").arrayFilter("tag", notEqual(variable("tag"), "magic")).alias("notMagicTags"), + arrayFilter("tags", "tag", notEqual(variable("tag"), "epic")).alias("notEpicTags"), + field("tags").arrayFilter("tag", equal(variable("tag"), "romance")).alias("noMatchingTags"), + ) + .execute(); + assertThat(waitFor(execute).getResults()) + .comparingElementsUsing(DATA_CORRESPONDENCE) + .containsExactly( + ImmutableMap.of( + "notMagicTags", + ImmutableList.of("adventure", "epic"), + "notEpicTags", + ImmutableList.of("adventure", "magic"), + "noMatchingTags", + ImmutableList.of())); + } + + @Test + public void arrayFilterWithMixedTypesAndNullsWorks() { + Task execute = firestore + .pipeline() + .collection(randomCol) + .limit(1) + .replaceWith( + map( + ImmutableMap.of( + "arr", + ImmutableList.of(1, "foo", null, 20.0, "bar", 30, "40", null)))) + .select( + field("arr") + .arrayFilter("element", greaterThan(variable("element"), 10)) + .alias("filtered")) + .execute(); + assertThat(waitFor(execute).getResults()) + .comparingElementsUsing(DATA_CORRESPONDENCE) + .containsExactly( + ImmutableMap.of( + "filtered", + ImmutableList.of("bar", "40"))); + } + + @Test + public void arraySliceWorks() { + Task execute = firestore + .pipeline() + .collection(randomCol) + .where(equal("title", "The Lord of the Rings")) + .select( + field("tags").arraySlice(1, 1).alias("instanceMethodSlice"), + arraySlice("tags", 1, 1).alias("staticMethodSlice"), + field("tags").arraySlice(1).alias("instanceMethodSliceToEnd"), + arraySlice("tags", 1).alias("staticMethodSliceToEnd")) + .execute(); + assertThat(waitFor(execute).getResults()) + .comparingElementsUsing(DATA_CORRESPONDENCE) + .containsExactly( + ImmutableMap.of( + "instanceMethodSlice", + ImmutableList.of("magic"), + "staticMethodSlice", + ImmutableList.of("magic"), + "instanceMethodSliceToEnd", + ImmutableList.of("magic", "epic"), + "staticMethodSliceToEnd", + ImmutableList.of("magic", "epic"))); + } + @Test public void arrayIndexOfWorks() { Task execute = diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/expressions.kt b/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/expressions.kt index 4e8525a06a7..b08aa324c1f 100644 --- a/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/expressions.kt +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/expressions.kt @@ -4753,6 +4753,106 @@ abstract class Expression internal constructor() { fun arrayReverse(arrayFieldName: String): Expression = FunctionExpression("array_reverse", evaluateArrayReverse, arrayFieldName) + /** + * Filters an [array] expression based on a predicate. + * + * ```kotlin + * // Filter 'scores' array to include only values greater than 50 + * arrayFilter(field("scores"), "score", greaterThan(field("score"), 50)) + * ``` + * + * @param array The array expression to filter. + * @param alias The alias to use for the current element in the filter expression. + * @param filter The predicate expression used to filter the elements. + * @return A new [Expression] representing the arrayFilter operation. + */ + @JvmStatic + fun arrayFilter(array: Expression, alias: String, filter: Expression): Expression = + FunctionExpression("array_filter", notImplemented, array, constant(alias), filter) + + /** + * Filters an array field based on a predicate. + * + * ```kotlin + * // Filter 'scores' array to include only values greater than 50 + * arrayFilter("scores", "score", greaterThan(field("score"), 50)) + * ``` + * + * @param arrayFieldName The name of field that contains array to filter. + * @param alias The alias to use for the current element in the filter expression. + * @param filter The predicate expression used to filter the elements. + * @return A new [Expression] representing the arrayFilter operation. + */ + @JvmStatic + fun arrayFilter(arrayFieldName: String, alias: String, filter: Expression): Expression = + FunctionExpression("array_filter", notImplemented, arrayFieldName, constant(alias), filter) + + /** + * Creates an expression that returns a slice of an [array] expression. + * + * ```kotlin + * // Get 5 elements from the 'items' array starting from index 2 + * arraySlice(field("items"), 2, 5) + * ``` + * + * @param array The array expression. + * @param offset The starting index. + * @param length The number of elements to return. + * @return A new [Expression] representing the arraySlice operation. + */ + @JvmStatic + fun arraySlice(array: Expression, offset: Any, length: Any): Expression = + FunctionExpression("array_slice", notImplemented, array, toExprOrConstant(offset), toExprOrConstant(length)) + + /** + * Creates an expression that returns a slice of an [array] expression to its end. + * + * ```kotlin + * // Get elements from the 'items' array starting from index 2 + * arraySlice(field("items"), 2) + * ``` + * + * @param array The array expression. + * @param offset The starting index. + * @return A new [Expression] representing the arraySlice operation. + */ + @JvmStatic + fun arraySlice(array: Expression, offset: Any): Expression = + FunctionExpression("array_slice", notImplemented, array, toExprOrConstant(offset)) + + /** + * Creates an expression that returns a slice of an array field. + * + * ```kotlin + * // Get 5 elements from the 'items' array starting from index 2 + * arraySlice("items", 2, 5) + * ``` + * + * @param arrayFieldName The name of field that contains the array. + * @param offset The starting index. + * @param length The number of elements to return. + * @return A new [Expression] representing the arraySlice operation. + */ + @JvmStatic + fun arraySlice(arrayFieldName: String, offset: Any, length: Any): Expression = + FunctionExpression("array_slice", notImplemented, arrayFieldName, toExprOrConstant(offset), toExprOrConstant(length)) + + /** + * Creates an expression that returns a slice of an array field to its end. + * + * ```kotlin + * // Get elements from the 'items' array starting from index 2 + * arraySlice("items", 2) + * ``` + * + * @param arrayFieldName The name of field that contains the array. + * @param offset The starting index. + * @return A new [Expression] representing the arraySlice operation. + */ + @JvmStatic + fun arraySlice(arrayFieldName: String, offset: Any): Expression = + FunctionExpression("array_slice", notImplemented, arrayFieldName, toExprOrConstant(offset)) + /** * Creates an expression that returns the sum of the elements in an array. * @@ -7475,6 +7575,48 @@ abstract class Expression internal constructor() { */ fun arrayReverse() = Companion.arrayReverse(this) + /** + * Filters this array expression based on a predicate. + * + * ```kotlin + * // Filter 'scores' array to include only values greater than 50 + * field("scores").arrayFilter("score", greaterThan(field("score"), 50)) + * ``` + * + * @param alias The alias to use for the current element in the filter expression. + * @param filter The predicate expression used to filter the elements. + * @return A new [Expression] representing the arrayFilter operation. + */ + fun arrayFilter(alias: String, filter: Expression) = + Companion.arrayFilter(this, alias, filter) + + /** + * Creates an expression that returns a slice of this array expression. + * + * ```kotlin + * // Get 5 elements from the 'items' array starting from index 2 + * field("items").arraySlice(2, 5) + * ``` + * + * @param offset The starting index. + * @param length The number of elements to return. + * @return A new [Expression] representing the arraySlice operation. + */ + fun arraySlice(offset: Any, length: Any) = Companion.arraySlice(this, offset, length) + + /** + * Creates an expression that returns a slice of this array expression to its end. + * + * ```kotlin + * // Get elements from the 'items' array starting from index 2 + * field("items").arraySlice(2) + * ``` + * + * @param offset The starting index. + * @return A new [Expression] representing the arraySlice operation. + */ + fun arraySlice(offset: Any) = Companion.arraySlice(this, offset) + /** * Creates an expression that returns the sum of the elements in this array expression. * From 71c42541caea1dd259eeb8d261da4f2ca825e2c7 Mon Sep 17 00:00:00 2001 From: cherylEnkidu Date: Fri, 20 Mar 2026 18:36:36 -0400 Subject: [PATCH 44/53] improve the documentation --- .../com/google/firebase/firestore/Pipeline.kt | 54 ++++++++++--------- 1 file changed, 30 insertions(+), 24 deletions(-) diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/Pipeline.kt b/firebase-firestore/src/main/java/com/google/firebase/firestore/Pipeline.kt index 5595dcdf13a..6b1f4aee9d6 100644 --- a/firebase-firestore/src/main/java/com/google/firebase/firestore/Pipeline.kt +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/Pipeline.kt @@ -914,8 +914,9 @@ internal constructor( * Defines one or more variables in the pipeline's scope. `define` is used to bind a value to a * variable for internal reuse within the pipeline body (accessed via the `variable()` function). * - * This stage is useful for declaring reusable values or intermediate calculations that can be - * referenced multiple times in later parts of the pipeline. + * This stage is particularly useful for passing values from an outer pipeline into a subquery, + * or for declaring reusable intermediate calculations that can be referenced multiple times + * in later parts of the pipeline via `variable()`. * * Each variable is defined using an [AliasedExpression], which pairs an expression with a name * (alias). The expression can be a simple constant, a field reference, or a function evaluation @@ -924,12 +925,13 @@ internal constructor( * Example: * ```kotlin * firestore.pipeline().collection("products") - * .define( - * multiply(field("price"), 0.9).as("discountedPrice"), - * add(field("stock"), 10).as("newStock") + * .define(field("category").alias("productCategory")) + * .addFields( + * firestore.pipeline().collection("categories") + * .where(field("name").equal(variable("productCategory"))) + * .select(field("description")) + * .toScalarExpression().alias("categoryDescription") * ) - * .where(lessThan(variable("discountedPrice"), 100)) - * .select(field("name"), variable("newStock")); * ``` * * @param aliasedExpression The first variable to define, specified as an [AliasedExpression]. @@ -1024,13 +1026,15 @@ internal constructor( * Example: * ```kotlin * // Calculate average rating for a restaurant - * db.pipeline().collection("restaurants").addFields( - * db.pipeline().collection("reviews") - * .where(field("restaurant_id").equal(variable("rid"))) - * .aggregate(AggregateFunction.average("rating").alias("avg")) - * // Unwraps the single "avg" field to a scalar double - * .toScalarExpression().alias("average_rating") - * ) + * db.pipeline().collection("restaurants") + * .define(field("id").alias("rid")) + * .addFields( + * db.pipeline().collection("reviews") + * .where(field("restaurant_id").equal(variable("rid"))) + * .aggregate(AggregateFunction.average("rating").alias("avg")) + * // Unwraps the single "avg" field to a scalar double + * .toScalarExpression().alias("average_rating") + * ) * ``` * * Output: @@ -1044,16 +1048,18 @@ internal constructor( * Example (Multiple Fields): * ```kotlin * // Calculate average rating AND count for a restaurant - * db.pipeline().collection("restaurants").addFields( - * db.pipeline().collection("reviews") - * .where(field("restaurant_id").equal(variable("rid"))) - * .aggregate( - * AggregateFunction.average("rating").alias("avg"), - * AggregateFunction.count().alias("count") - * ) - * // Returns a Map with "avg" and "count" fields - * .toScalarExpression().alias("stats") - * ) + * db.pipeline().collection("restaurants") + * .define(field("id").alias("rid")) + * .addFields( + * db.pipeline().collection("reviews") + * .where(field("restaurant_id").equal(variable("rid"))) + * .aggregate( + * AggregateFunction.average("rating").alias("avg"), + * AggregateFunction.count().alias("count") + * ) + * // Returns a Map with "avg" and "count" fields + * .toScalarExpression().alias("stats") + * ) * ``` * * Output: From 0c3b1f55b8dd9ec291b444c3ef6fddac3c3ee78b Mon Sep 17 00:00:00 2001 From: Mila <107142260+milaGGL@users.noreply.github.com> Date: Mon, 23 Mar 2026 13:36:28 -0400 Subject: [PATCH 45/53] update api file --- firebase-firestore/api.txt | 15 ++ .../firebase/firestore/PipelineTest.java | 147 ++++++++++-------- .../firestore/pipeline/expressions.kt | 34 ++-- 3 files changed, 122 insertions(+), 74 deletions(-) diff --git a/firebase-firestore/api.txt b/firebase-firestore/api.txt index f3b6bb2e222..16623b23151 100644 --- a/firebase-firestore/api.txt +++ b/firebase-firestore/api.txt @@ -836,6 +836,9 @@ package com.google.firebase.firestore.pipeline { method public static final com.google.firebase.firestore.pipeline.BooleanExpression arrayContainsAny(String arrayFieldName, com.google.firebase.firestore.pipeline.Expression arrayExpression); method public static final com.google.firebase.firestore.pipeline.BooleanExpression arrayContainsAny(String arrayFieldName, java.util.List values); method public final com.google.firebase.firestore.pipeline.BooleanExpression arrayContainsAny(java.util.List values); + method public static final com.google.firebase.firestore.pipeline.Expression arrayFilter(com.google.firebase.firestore.pipeline.Expression array, String alias, com.google.firebase.firestore.pipeline.BooleanExpression filter); + method public final com.google.firebase.firestore.pipeline.Expression arrayFilter(String alias, com.google.firebase.firestore.pipeline.BooleanExpression filter); + method public static final com.google.firebase.firestore.pipeline.Expression arrayFilter(String arrayFieldName, String alias, com.google.firebase.firestore.pipeline.BooleanExpression filter); method public final com.google.firebase.firestore.pipeline.Expression arrayFirst(); method public static final com.google.firebase.firestore.pipeline.Expression arrayFirst(com.google.firebase.firestore.pipeline.Expression array); method public static final com.google.firebase.firestore.pipeline.Expression arrayFirst(String arrayFieldName); @@ -893,6 +896,12 @@ package com.google.firebase.firestore.pipeline { method public final com.google.firebase.firestore.pipeline.Expression arrayReverse(); method public static final com.google.firebase.firestore.pipeline.Expression arrayReverse(com.google.firebase.firestore.pipeline.Expression array); method public static final com.google.firebase.firestore.pipeline.Expression arrayReverse(String arrayFieldName); + method public static final com.google.firebase.firestore.pipeline.Expression arraySlice(com.google.firebase.firestore.pipeline.Expression array, Object offset); + method public static final com.google.firebase.firestore.pipeline.Expression arraySlice(com.google.firebase.firestore.pipeline.Expression array, Object offset, Object length); + method public final com.google.firebase.firestore.pipeline.Expression arraySlice(Object offset); + method public final com.google.firebase.firestore.pipeline.Expression arraySlice(Object offset, Object length); + method public static final com.google.firebase.firestore.pipeline.Expression arraySlice(String arrayFieldName, Object offset); + method public static final com.google.firebase.firestore.pipeline.Expression arraySlice(String arrayFieldName, Object offset, Object length); method public final com.google.firebase.firestore.pipeline.Expression arraySum(); method public static final com.google.firebase.firestore.pipeline.Expression arraySum(com.google.firebase.firestore.pipeline.Expression array); method public static final com.google.firebase.firestore.pipeline.Expression arraySum(String arrayFieldName); @@ -1337,6 +1346,8 @@ package com.google.firebase.firestore.pipeline { method public com.google.firebase.firestore.pipeline.BooleanExpression arrayContainsAny(com.google.firebase.firestore.pipeline.Expression array, java.util.List values); method public com.google.firebase.firestore.pipeline.BooleanExpression arrayContainsAny(String arrayFieldName, com.google.firebase.firestore.pipeline.Expression arrayExpression); method public com.google.firebase.firestore.pipeline.BooleanExpression arrayContainsAny(String arrayFieldName, java.util.List values); + method public com.google.firebase.firestore.pipeline.Expression arrayFilter(com.google.firebase.firestore.pipeline.Expression array, String alias, com.google.firebase.firestore.pipeline.BooleanExpression filter); + method public com.google.firebase.firestore.pipeline.Expression arrayFilter(String arrayFieldName, String alias, com.google.firebase.firestore.pipeline.BooleanExpression filter); method public com.google.firebase.firestore.pipeline.Expression arrayFirst(com.google.firebase.firestore.pipeline.Expression array); method public com.google.firebase.firestore.pipeline.Expression arrayFirst(String arrayFieldName); method public com.google.firebase.firestore.pipeline.Expression arrayFirstN(com.google.firebase.firestore.pipeline.Expression array, com.google.firebase.firestore.pipeline.Expression n); @@ -1375,6 +1386,10 @@ package com.google.firebase.firestore.pipeline { method public com.google.firebase.firestore.pipeline.Expression arrayMinimumN(String arrayFieldName, int n); method public com.google.firebase.firestore.pipeline.Expression arrayReverse(com.google.firebase.firestore.pipeline.Expression array); method public com.google.firebase.firestore.pipeline.Expression arrayReverse(String arrayFieldName); + method public com.google.firebase.firestore.pipeline.Expression arraySlice(com.google.firebase.firestore.pipeline.Expression array, Object offset); + method public com.google.firebase.firestore.pipeline.Expression arraySlice(com.google.firebase.firestore.pipeline.Expression array, Object offset, Object length); + method public com.google.firebase.firestore.pipeline.Expression arraySlice(String arrayFieldName, Object offset); + method public com.google.firebase.firestore.pipeline.Expression arraySlice(String arrayFieldName, Object offset, Object length); method public com.google.firebase.firestore.pipeline.Expression arraySum(com.google.firebase.firestore.pipeline.Expression array); method public com.google.firebase.firestore.pipeline.Expression arraySum(String arrayFieldName); method public com.google.firebase.firestore.pipeline.Expression bitAnd(com.google.firebase.firestore.pipeline.Expression bits, byte[] bitsOther); diff --git a/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/PipelineTest.java b/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/PipelineTest.java index a0853ee4b1a..0701a118286 100644 --- a/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/PipelineTest.java +++ b/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/PipelineTest.java @@ -858,78 +858,99 @@ public void arrayMaximumNWorks() { ImmutableList.of("magic", "epic", "adventure"))); } -@Test + @Test public void arrayFilterWorks() { - Task execute = firestore - .pipeline() - .collection(randomCol) - .where(equal("title", "The Lord of the Rings")) - .select( - field("tags").arrayFilter("tag", notEqual(variable("tag"), "magic")).alias("notMagicTags"), - arrayFilter("tags", "tag", notEqual(variable("tag"), "epic")).alias("notEpicTags"), - field("tags").arrayFilter("tag", equal(variable("tag"), "romance")).alias("noMatchingTags"), - ) - .execute(); - assertThat(waitFor(execute).getResults()) - .comparingElementsUsing(DATA_CORRESPONDENCE) - .containsExactly( - ImmutableMap.of( - "notMagicTags", - ImmutableList.of("adventure", "epic"), - "notEpicTags", - ImmutableList.of("adventure", "magic"), - "noMatchingTags", - ImmutableList.of())); + Task execute = + firestore + .pipeline() + .collection(randomCol) + .where(equal("title", "The Lord of the Rings")) + .select( + field("tags") + .arrayFilter("tag", notEqual(field("tag"), "magic")) + .alias("notMagicTags"), + arrayFilter("tags", "tag", notEqual(field("tag"), "epic")).alias("notEpicTags"), + field("tags") + .arrayFilter("tag", equal(field("tag"), "romance")) + .alias("noMatchingTags")) + .execute(); + assertThat(waitFor(execute).getResults()) + .comparingElementsUsing(DATA_CORRESPONDENCE) + .containsExactly( + ImmutableMap.of( + "notMagicTags", + ImmutableList.of("adventure", "epic"), + "notEpicTags", + ImmutableList.of("adventure", "magic"), + "noMatchingTags", + ImmutableList.of())); } @Test public void arrayFilterWithMixedTypesAndNullsWorks() { - Task execute = firestore - .pipeline() - .collection(randomCol) - .limit(1) - .replaceWith( - map( - ImmutableMap.of( - "arr", - ImmutableList.of(1, "foo", null, 20.0, "bar", 30, "40", null)))) - .select( - field("arr") - .arrayFilter("element", greaterThan(variable("element"), 10)) - .alias("filtered")) - .execute(); - assertThat(waitFor(execute).getResults()) - .comparingElementsUsing(DATA_CORRESPONDENCE) - .containsExactly( - ImmutableMap.of( - "filtered", - ImmutableList.of("bar", "40"))); + Task execute = + firestore + .pipeline() + .collection(randomCol) + .limit(1) + .replaceWith( + map( + ImmutableMap.of( + "arr", ImmutableList.of(1, "foo", null, 20.0, "bar", 30, "40", null)))) + .select( + field("arr") + .arrayFilter("element", greaterThan(field("element"), 10)) + .alias("filtered")) + .execute(); + assertThat(waitFor(execute).getResults()) + .comparingElementsUsing(DATA_CORRESPONDENCE) + .containsExactly(ImmutableMap.of("filtered", ImmutableList.of("bar", "40"))); } @Test public void arraySliceWorks() { - Task execute = firestore - .pipeline() - .collection(randomCol) - .where(equal("title", "The Lord of the Rings")) - .select( - field("tags").arraySlice(1, 1).alias("instanceMethodSlice"), - arraySlice("tags", 1, 1).alias("staticMethodSlice"), - field("tags").arraySlice(1).alias("instanceMethodSliceToEnd"), - arraySlice("tags", 1).alias("staticMethodSliceToEnd")) - .execute(); - assertThat(waitFor(execute).getResults()) - .comparingElementsUsing(DATA_CORRESPONDENCE) - .containsExactly( - ImmutableMap.of( - "instanceMethodSlice", - ImmutableList.of("magic"), - "staticMethodSlice", - ImmutableList.of("magic"), - "instanceMethodSliceToEnd", - ImmutableList.of("magic", "epic"), - "staticMethodSliceToEnd", - ImmutableList.of("magic", "epic"))); + Task execute = + firestore + .pipeline() + .collection(randomCol) + .where(equal("title", "The Lord of the Rings")) + .select( + arraySlice("tags", 1, 1).alias("staticMethodSlice"), + arraySlice("tags", 1).alias("staticMethodSliceToEnd"), + field("tags").arraySlice(1, 1).alias("instanceMethodSlice"), + field("tags").arraySlice(1).alias("instanceMethodSliceToEnd"), + field("tags").arraySlice(1, 10).alias("overflowLength"), + field("tags").arraySlice(-1, 1).alias("negativeOffset"), + field("tags").arraySlice(-1).alias("negativeOffsetSliceToEnd"), + field("tags").arraySlice(10).alias("overflowOffset"), + field("tags").arraySlice(-10).alias("negativeOverflowOffset")) + .execute(); + assertThat(waitFor(execute).getResults()) + .comparingElementsUsing(DATA_CORRESPONDENCE) + .containsExactly( + mapOfEntries( + entry("staticMethodSlice", ImmutableList.of("magic")), + entry("staticMethodSliceToEnd", ImmutableList.of("magic", "epic")), + entry("instanceMethodSlice", ImmutableList.of("magic")), + entry("instanceMethodSliceToEnd", ImmutableList.of("magic", "epic")), + entry("overflowLength", ImmutableList.of("magic", "epic")), + entry("overflowOffset", ImmutableList.of()), + entry("negativeOffset", ImmutableList.of("epic")), + entry("negativeOffsetSliceToEnd", ImmutableList.of("epic")), + entry("negativeOverflowOffset", ImmutableList.of("adventure", "magic", "epic")))); + } + + @Test + public void arraySliceThrowsErrorForNegativeLength() { + Task execute = + firestore + .pipeline() + .collection(randomCol) + .where(equal("title", "The Lord of the Rings")) + .select(arraySlice("tags", 1, -1).alias("negativeLengthSlice")) + .execute(); + Exception exception = assertThrows(Exception.class, () -> waitFor(execute)); + assertThat(exception).hasMessageThat().contains("length must be non-negative"); } @Test diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/expressions.kt b/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/expressions.kt index b08aa324c1f..aa766bb0b67 100644 --- a/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/expressions.kt +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/expressions.kt @@ -4758,16 +4758,16 @@ abstract class Expression internal constructor() { * * ```kotlin * // Filter 'scores' array to include only values greater than 50 - * arrayFilter(field("scores"), "score", greaterThan(field("score"), 50)) + * arrayFilter(field("scores"), "score", greaterThan(variable("score"), 50)) * ``` * * @param array The array expression to filter. * @param alias The alias to use for the current element in the filter expression. - * @param filter The predicate expression used to filter the elements. + * @param filter The predicate boolean expression used to filter the elements. * @return A new [Expression] representing the arrayFilter operation. */ @JvmStatic - fun arrayFilter(array: Expression, alias: String, filter: Expression): Expression = + fun arrayFilter(array: Expression, alias: String, filter: BooleanExpression): Expression = FunctionExpression("array_filter", notImplemented, array, constant(alias), filter) /** @@ -4775,16 +4775,16 @@ abstract class Expression internal constructor() { * * ```kotlin * // Filter 'scores' array to include only values greater than 50 - * arrayFilter("scores", "score", greaterThan(field("score"), 50)) + * arrayFilter("scores", "score", greaterThan(variable("score"), 50)) * ``` * * @param arrayFieldName The name of field that contains array to filter. * @param alias The alias to use for the current element in the filter expression. - * @param filter The predicate expression used to filter the elements. + * @param filter The predicate boolean expression used to filter the elements. * @return A new [Expression] representing the arrayFilter operation. */ @JvmStatic - fun arrayFilter(arrayFieldName: String, alias: String, filter: Expression): Expression = + fun arrayFilter(arrayFieldName: String, alias: String, filter: BooleanExpression): Expression = FunctionExpression("array_filter", notImplemented, arrayFieldName, constant(alias), filter) /** @@ -4802,7 +4802,13 @@ abstract class Expression internal constructor() { */ @JvmStatic fun arraySlice(array: Expression, offset: Any, length: Any): Expression = - FunctionExpression("array_slice", notImplemented, array, toExprOrConstant(offset), toExprOrConstant(length)) + FunctionExpression( + "array_slice", + notImplemented, + array, + toExprOrConstant(offset), + toExprOrConstant(length) + ) /** * Creates an expression that returns a slice of an [array] expression to its end. @@ -4835,7 +4841,13 @@ abstract class Expression internal constructor() { */ @JvmStatic fun arraySlice(arrayFieldName: String, offset: Any, length: Any): Expression = - FunctionExpression("array_slice", notImplemented, arrayFieldName, toExprOrConstant(offset), toExprOrConstant(length)) + FunctionExpression( + "array_slice", + notImplemented, + arrayFieldName, + toExprOrConstant(offset), + toExprOrConstant(length) + ) /** * Creates an expression that returns a slice of an array field to its end. @@ -7580,14 +7592,14 @@ abstract class Expression internal constructor() { * * ```kotlin * // Filter 'scores' array to include only values greater than 50 - * field("scores").arrayFilter("score", greaterThan(field("score"), 50)) + * field("scores").arrayFilter("score", greaterThan(variable("score"), 50)) * ``` * * @param alias The alias to use for the current element in the filter expression. - * @param filter The predicate expression used to filter the elements. + * @param filter The predicate boolean expression used to filter the elements. * @return A new [Expression] representing the arrayFilter operation. */ - fun arrayFilter(alias: String, filter: Expression) = + fun arrayFilter(alias: String, filter: BooleanExpression) = Companion.arrayFilter(this, alias, filter) /** From aa98923803dd6545011b4662aaf1fbbd7588bcb6 Mon Sep 17 00:00:00 2001 From: cherylEnkidu Date: Mon, 23 Mar 2026 13:59:04 -0400 Subject: [PATCH 46/53] format code --- .../src/main/java/com/google/firebase/firestore/Pipeline.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/Pipeline.kt b/firebase-firestore/src/main/java/com/google/firebase/firestore/Pipeline.kt index 6b1f4aee9d6..86946541ca3 100644 --- a/firebase-firestore/src/main/java/com/google/firebase/firestore/Pipeline.kt +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/Pipeline.kt @@ -914,9 +914,9 @@ internal constructor( * Defines one or more variables in the pipeline's scope. `define` is used to bind a value to a * variable for internal reuse within the pipeline body (accessed via the `variable()` function). * - * This stage is particularly useful for passing values from an outer pipeline into a subquery, - * or for declaring reusable intermediate calculations that can be referenced multiple times - * in later parts of the pipeline via `variable()`. + * This stage is particularly useful for passing values from an outer pipeline into a subquery, or + * for declaring reusable intermediate calculations that can be referenced multiple times in later + * parts of the pipeline via `variable()`. * * Each variable is defined using an [AliasedExpression], which pairs an expression with a name * (alias). The expression can be a simple constant, a field reference, or a function evaluation From 107d6086cdd3fec351d33b20fd02d1930ed49d59 Mon Sep 17 00:00:00 2001 From: cherylEnkidu Date: Mon, 23 Mar 2026 14:17:18 -0400 Subject: [PATCH 47/53] add change log --- firebase-firestore/CHANGELOG.md | 2 ++ firebase-firestore/gradle.properties | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/firebase-firestore/CHANGELOG.md b/firebase-firestore/CHANGELOG.md index 52d77c8d351..fe93a84c3e6 100644 --- a/firebase-firestore/CHANGELOG.md +++ b/firebase-firestore/CHANGELOG.md @@ -1,5 +1,7 @@ # Unreleased +- [feature] Added support for Pipeline Subqueries, including `define` and `variable` expressions. + [#7736](https://github.com/firebase/firebase-android-sdk/pull/7736) - [feature] Added support for Pipeline expressions `nor` and `switchOn`. [#7903](https://github.com/firebase/firebase-android-sdk/pull/7903) - [feature] Added support for `first`, `last`, `arrayAgg`, and `arrayAggDistinct` Pipeline expressions. diff --git a/firebase-firestore/gradle.properties b/firebase-firestore/gradle.properties index 44858779498..9ebd7dfb6c4 100644 --- a/firebase-firestore/gradle.properties +++ b/firebase-firestore/gradle.properties @@ -1,2 +1,2 @@ -version=26.1.3 +version=26.2.3 latestReleasedVersion=26.1.2 From 62e155e1d98316c9c8ca6fc931cec1455ac37e02 Mon Sep 17 00:00:00 2001 From: cherylEnkidu Date: Wed, 25 Mar 2026 16:25:09 -0400 Subject: [PATCH 48/53] fix test --- .../firebase/firestore/PipelineTest.java | 42 +++++++++++++++---- 1 file changed, 35 insertions(+), 7 deletions(-) diff --git a/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/PipelineTest.java b/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/PipelineTest.java index f6f1f6d2d1e..057071b2390 100644 --- a/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/PipelineTest.java +++ b/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/PipelineTest.java @@ -1772,11 +1772,23 @@ public void testNestedFields() { .select("title", "awards.hugo") .sort(field("title").descending()) .execute(); + Map book1Expected; + Map book10Expected; + if (IntegrationTestUtil.getTargetBackend() == IntegrationTestUtil.TargetBackend.NIGHTLY) { + book1Expected = + mapOfEntries( + entry("title", "The Hitchhiker's Guide to the Galaxy"), + entry("awards", ImmutableMap.of("hugo", true))); + book10Expected = + mapOfEntries(entry("title", "Dune"), entry("awards", ImmutableMap.of("hugo", true))); + } else { + book1Expected = + ImmutableMap.of("title", "The Hitchhiker's Guide to the Galaxy", "awards.hugo", true); + book10Expected = ImmutableMap.of("title", "Dune", "awards.hugo", true); + } assertThat(waitFor(execute).getResults()) .comparingElementsUsing(DATA_CORRESPONDENCE) - .containsExactly( - ImmutableMap.of("title", "The Hitchhiker's Guide to the Galaxy", "awards.hugo", true), - ImmutableMap.of("title", "Dune", "awards.hugo", true)); + .containsExactly(book1Expected, book10Expected); } @Test @@ -1792,12 +1804,28 @@ public void testMapGetWithFieldNameIncludingNotation() { field("nestedField.level.1"), mapGet("nestedField", "level.1").mapGet("level.2").alias("nested")) .execute(); + Map book1Expected; + Map book10Expected; + if (IntegrationTestUtil.getTargetBackend() == IntegrationTestUtil.TargetBackend.NIGHTLY) { + book1Expected = + mapOfEntries( + entry("title", "The Hitchhiker's Guide to the Galaxy"), + entry("nestedField", ImmutableMap.of("level", ImmutableMap.of())), + entry("nested", true)); + book10Expected = + mapOfEntries( + entry("title", "Dune"), + entry("nestedField", ImmutableMap.of("level", ImmutableMap.of())), + entry("nested", null)); + } else { + book1Expected = + mapOfEntries( + entry("title", "The Hitchhiker's Guide to the Galaxy"), entry("nested", true)); + book10Expected = mapOfEntries(entry("title", "Dune")); + } assertThat(waitFor(execute).getResults()) .comparingElementsUsing(DATA_CORRESPONDENCE) - .containsExactly( - mapOfEntries( - entry("title", "The Hitchhiker's Guide to the Galaxy"), entry("nested", true)), - mapOfEntries(entry("title", "Dune"))); + .containsExactly(book1Expected, book10Expected); } @Test From 6a60ca8eb4728e56adc0270b4f1fdeab001eb787 Mon Sep 17 00:00:00 2001 From: cherylEnkidu Date: Thu, 26 Mar 2026 17:45:54 -0400 Subject: [PATCH 49/53] fix bug --- .../firebase/firestore/testutil/IntegrationTestUtil.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/testutil/IntegrationTestUtil.java b/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/testutil/IntegrationTestUtil.java index ef96ee19b3e..7e85b4aef20 100644 --- a/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/testutil/IntegrationTestUtil.java +++ b/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/testutil/IntegrationTestUtil.java @@ -105,8 +105,8 @@ public enum BackendEdition { // Set this to the desired enum value to change the target backend when running tests locally. // Note: DO NOT change this variable except for local testing. - private static final TargetBackend backendForLocalTesting = TargetBackend.NIGHTLY; - private static final BackendEdition backendEditionForLocalTesting = BackendEdition.ENTERPRISE; + private static final TargetBackend backendForLocalTesting = null; + private static final BackendEdition backendEditionForLocalTesting = null; private static final TargetBackend backend = getTargetBackend(); private static final String EMULATOR_HOST = "10.0.2.2"; From bde854627ee10c80e58567d2b5b791b7d5b97590 Mon Sep 17 00:00:00 2001 From: cherylEnkidu Date: Thu, 26 Mar 2026 17:48:06 -0400 Subject: [PATCH 50/53] fix merge issue --- firebase-firestore/api.txt | 6 +----- .../java/com/google/firebase/firestore/PipelineTest.java | 2 ++ 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/firebase-firestore/api.txt b/firebase-firestore/api.txt index 1b278a04471..567de23c0a0 100644 --- a/firebase-firestore/api.txt +++ b/firebase-firestore/api.txt @@ -1798,8 +1798,7 @@ package com.google.firebase.firestore.pipeline { method public final T withOption(String key, long value); } -<<<<<<< HEAD - @com.google.common.annotations.Beta public final class SubcollectionSource extends com.google.firebase.firestore.pipeline.Stage { + public final class SubcollectionSource extends com.google.firebase.firestore.pipeline.Stage { method public static com.google.firebase.firestore.pipeline.SubcollectionSource of(String path); field public static final com.google.firebase.firestore.pipeline.SubcollectionSource.Companion Companion; } @@ -1808,10 +1807,7 @@ package com.google.firebase.firestore.pipeline { method public com.google.firebase.firestore.pipeline.SubcollectionSource of(String path); } - @com.google.common.annotations.Beta public final class UnnestOptions extends com.google.firebase.firestore.pipeline.AbstractOptions { -======= public final class UnnestOptions extends com.google.firebase.firestore.pipeline.AbstractOptions { ->>>>>>> main ctor public UnnestOptions(); method public com.google.firebase.firestore.pipeline.UnnestOptions withIndexField(String indexField); } diff --git a/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/PipelineTest.java b/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/PipelineTest.java index e5b985146c0..419c0f893c1 100644 --- a/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/PipelineTest.java +++ b/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/PipelineTest.java @@ -1772,6 +1772,7 @@ public void testNestedFields() { .select("title", "awards.hugo") .sort(field("title").descending()) .execute(); + Map hitchhikerResult; Map duneResult; @@ -1810,6 +1811,7 @@ public void testMapGetWithFieldNameIncludingNotation() { field("nestedField.level.1"), mapGet("nestedField", "level.1").mapGet("level.2").alias("nested")) .execute(); + Map hitchhikerResult; Map duneResult; From e795692431bfaf94685692dd78f9bca590e77728 Mon Sep 17 00:00:00 2001 From: Mila <107142260+milaGGL@users.noreply.github.com> Date: Sat, 28 Mar 2026 18:40:17 -0400 Subject: [PATCH 51/53] add array transform --- firebase-firestore/api.txt | 10 ++ .../firebase/firestore/PipelineTest.java | 97 +++++++++++- .../firestore/pipeline/expressions.kt | 140 ++++++++++++++++++ 3 files changed, 241 insertions(+), 6 deletions(-) diff --git a/firebase-firestore/api.txt b/firebase-firestore/api.txt index 7d0bb2c99de..5abfd071136 100644 --- a/firebase-firestore/api.txt +++ b/firebase-firestore/api.txt @@ -916,6 +916,12 @@ package com.google.firebase.firestore.pipeline { method public final com.google.firebase.firestore.pipeline.Expression arraySum(); method public static final com.google.firebase.firestore.pipeline.Expression arraySum(com.google.firebase.firestore.pipeline.Expression array); method public static final com.google.firebase.firestore.pipeline.Expression arraySum(String arrayFieldName); + method public static final com.google.firebase.firestore.pipeline.Expression arrayTransform(com.google.firebase.firestore.pipeline.Expression array, String elementAlias, com.google.firebase.firestore.pipeline.Expression transform); + method public final com.google.firebase.firestore.pipeline.Expression arrayTransform(String elementAlias, com.google.firebase.firestore.pipeline.Expression transform); + method public static final com.google.firebase.firestore.pipeline.Expression arrayTransform(String fieldName, String elementAlias, com.google.firebase.firestore.pipeline.Expression transform); + method public static final com.google.firebase.firestore.pipeline.Expression arrayTransformWithIndex(com.google.firebase.firestore.pipeline.Expression array, String elementAlias, String indexAlias, com.google.firebase.firestore.pipeline.Expression transform); + method public final com.google.firebase.firestore.pipeline.Expression arrayTransformWithIndex(String elementAlias, String indexAlias, com.google.firebase.firestore.pipeline.Expression transform); + method public static final com.google.firebase.firestore.pipeline.Expression arrayTransformWithIndex(String fieldName, String elementAlias, String indexAlias, com.google.firebase.firestore.pipeline.Expression transform); method public final com.google.firebase.firestore.pipeline.BooleanExpression asBoolean(); method public final com.google.firebase.firestore.pipeline.Ordering ascending(); method public final com.google.firebase.firestore.pipeline.AggregateFunction average(); @@ -1413,6 +1419,10 @@ package com.google.firebase.firestore.pipeline { method public com.google.firebase.firestore.pipeline.Expression arraySlice(String arrayFieldName, Object offset, Object length); method public com.google.firebase.firestore.pipeline.Expression arraySum(com.google.firebase.firestore.pipeline.Expression array); method public com.google.firebase.firestore.pipeline.Expression arraySum(String arrayFieldName); + method public com.google.firebase.firestore.pipeline.Expression arrayTransform(com.google.firebase.firestore.pipeline.Expression array, String elementAlias, com.google.firebase.firestore.pipeline.Expression transform); + method public com.google.firebase.firestore.pipeline.Expression arrayTransform(String fieldName, String elementAlias, com.google.firebase.firestore.pipeline.Expression transform); + method public com.google.firebase.firestore.pipeline.Expression arrayTransformWithIndex(com.google.firebase.firestore.pipeline.Expression array, String elementAlias, String indexAlias, com.google.firebase.firestore.pipeline.Expression transform); + method public com.google.firebase.firestore.pipeline.Expression arrayTransformWithIndex(String fieldName, String elementAlias, String indexAlias, com.google.firebase.firestore.pipeline.Expression transform); method public com.google.firebase.firestore.pipeline.Expression bitAnd(com.google.firebase.firestore.pipeline.Expression bits, byte[] bitsOther); method public com.google.firebase.firestore.pipeline.Expression bitAnd(com.google.firebase.firestore.pipeline.Expression bits, com.google.firebase.firestore.pipeline.Expression bitsOther); method public com.google.firebase.firestore.pipeline.Expression bitAnd(String bitsFieldName, byte[] bitsOther); diff --git a/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/PipelineTest.java b/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/PipelineTest.java index 6494fa0aaab..da72f54efa4 100644 --- a/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/PipelineTest.java +++ b/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/PipelineTest.java @@ -33,6 +33,8 @@ import static com.google.firebase.firestore.pipeline.Expression.arrayMinimum; import static com.google.firebase.firestore.pipeline.Expression.arrayMinimumN; import static com.google.firebase.firestore.pipeline.Expression.arraySlice; +import static com.google.firebase.firestore.pipeline.Expression.arrayTransform; +import static com.google.firebase.firestore.pipeline.Expression.arrayTransformWithIndex; import static com.google.firebase.firestore.pipeline.Expression.collectionId; import static com.google.firebase.firestore.pipeline.Expression.concat; import static com.google.firebase.firestore.pipeline.Expression.constant; @@ -52,6 +54,7 @@ import static com.google.firebase.firestore.pipeline.Expression.logicalMinimum; import static com.google.firebase.firestore.pipeline.Expression.map; import static com.google.firebase.firestore.pipeline.Expression.mapGet; +import static com.google.firebase.firestore.pipeline.Expression.multiply; import static com.google.firebase.firestore.pipeline.Expression.nor; import static com.google.firebase.firestore.pipeline.Expression.not; import static com.google.firebase.firestore.pipeline.Expression.notEqual; @@ -65,6 +68,7 @@ import static com.google.firebase.firestore.pipeline.Expression.switchOn; import static com.google.firebase.firestore.pipeline.Expression.trunc; import static com.google.firebase.firestore.pipeline.Expression.truncToPrecision; +import static com.google.firebase.firestore.pipeline.Expression.variable; import static com.google.firebase.firestore.pipeline.Expression.vector; import static com.google.firebase.firestore.pipeline.Ordering.ascending; import static com.google.firebase.firestore.pipeline.Ordering.descending; @@ -96,6 +100,7 @@ import com.google.firebase.firestore.pipeline.RawStage; import com.google.firebase.firestore.pipeline.UnnestOptions; import com.google.firebase.firestore.testutil.IntegrationTestUtil; +import java.util.Arrays; import java.util.Calendar; import java.util.Collections; import java.util.Date; @@ -971,11 +976,11 @@ public void arrayFilterWorks() { .where(equal("title", "The Lord of the Rings")) .select( field("tags") - .arrayFilter("tag", notEqual(field("tag"), "magic")) + .arrayFilter("tag", notEqual(variable("tag"), "magic")) .alias("notMagicTags"), - arrayFilter("tags", "tag", notEqual(field("tag"), "epic")).alias("notEpicTags"), + arrayFilter("tags", "tag", notEqual(variable("tag"), "epic")).alias("notEpicTags"), field("tags") - .arrayFilter("tag", equal(field("tag"), "romance")) + .arrayFilter("tag", equal(variable("tag"), "romance")) .alias("noMatchingTags")) .execute(); assertThat(waitFor(execute).getResults()) @@ -1000,15 +1005,95 @@ public void arrayFilterWithMixedTypesAndNullsWorks() { .replaceWith( map( ImmutableMap.of( - "arr", ImmutableList.of(1, "foo", null, 20.0, "bar", 30, "40", null)))) + "arr", + ImmutableList.of( + 1, + "foo", + Expression.nullValue(), + 20.0, + "bar", + 30, + "40", + Expression.nullValue())))) .select( field("arr") - .arrayFilter("element", greaterThan(field("element"), 10)) + .arrayFilter("element", greaterThan(variable("element"), 10)) .alias("filtered")) .execute(); assertThat(waitFor(execute).getResults()) .comparingElementsUsing(DATA_CORRESPONDENCE) - .containsExactly(ImmutableMap.of("filtered", ImmutableList.of("bar", "40"))); + .containsExactly(ImmutableMap.of("filtered", ImmutableList.of(20.0, 30L))); + } + + @Test + public void supportsArrayTransformAndArrayTransformWithIndex() { + Task execute = + firestore + .pipeline() + .collection(randomCol) + .limit(1) + .replaceWith(map(ImmutableMap.of("arr", Arrays.asList(10, 20, 30)))) + .select( + arrayTransform("arr", "element", multiply(variable("element"), 10)) + .alias("staticTransform"), + field("arr") + .arrayTransform("element", multiply(variable("element"), 10)) + .alias("instanceTransform"), + arrayTransformWithIndex( + "arr", "element", "i", add(variable("element"), variable("i"))) + .alias("staticTransformWithIndex"), + field("arr") + .arrayTransformWithIndex( + "element", "i", add(variable("element"), variable("i"))) + .alias("instanceTransformWithIndex")) + .execute(); + assertThat(waitFor(execute).getResults()) + .comparingElementsUsing(DATA_CORRESPONDENCE) + .containsExactly( + ImmutableMap.of( + "staticTransform", + ImmutableList.of(100L, 200L, 300L), + "instanceTransform", + ImmutableList.of(100L, 200L, 300L), + "staticTransformWithIndex", + ImmutableList.of(10L, 21L, 32L), + "instanceTransformWithIndex", + ImmutableList.of(10L, 21L, 32L))); + } + + @Test + public void supportsArrayTransformWithEmptyArrayAndNulls() { + Task execute = + firestore + .pipeline() + .collection(randomCol) + .limit(1) + .replaceWith( + map(ImmutableMap.of("arr", Arrays.asList(1, null, 3), "empty", ImmutableList.of()))) + .select( + field("arr") + .arrayTransform("element", add(variable("element"), 1)) + .alias("transformedWithNulls"), + field("empty") + .arrayTransform("element", add(variable("element"), 1)) + .alias("transformedEmpty"), + field("arr") + .arrayTransformWithIndex( + "element", "idx", add(variable("element"), variable("idx"))) + .alias("transformedWithIndex"), + field("empty") + .arrayTransformWithIndex( + "element", "idx", add(variable("element"), variable("idx"))) + .alias("transformedEmptyWithIndex")) + .execute(); + Map expectedMap = new HashMap<>(); + expectedMap.put("transformedWithNulls", Arrays.asList(2L, null, 4L)); + expectedMap.put("transformedEmpty", ImmutableList.of()); + expectedMap.put("transformedWithIndex", Arrays.asList(1L, null, 5L)); + expectedMap.put("transformedEmptyWithIndex", ImmutableList.of()); + assertThat(waitFor(execute).getResults()) + .comparingElementsUsing(DATA_CORRESPONDENCE) + .containsExactly(expectedMap); } @Test diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/expressions.kt b/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/expressions.kt index 07026ea490d..b75261d39b8 100644 --- a/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/expressions.kt +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/expressions.kt @@ -4879,6 +4879,114 @@ abstract class Expression internal constructor() { fun arrayFilter(arrayFieldName: String, alias: String, filter: BooleanExpression): Expression = FunctionExpression("array_filter", notImplemented, arrayFieldName, constant(alias), filter) + /** + * Creates an expression that applies a provided transformation to each element in an array. + * + * ```kotlin + * // Transform 'scores' array by multiplying each score by 10 + * arrayTransform(field("scores"), "score", multiply(variable("score"), 10)) + * ``` + * + * @param array The array expression to transform. + * @param elementAlias The alias to use for the current element in the transform expression. + * @param transform The expression used to transform the elements. + * @return A new [Expression] representing the arrayTransform operation. + */ + @JvmStatic + fun arrayTransform(array: Expression, elementAlias: String, transform: Expression): Expression = + FunctionExpression( + "array_transform", + notImplemented, + array, + constant(elementAlias), + transform + ) + + /** + * Creates an expression that applies a provided transformation to each element in an array. + * + * ```kotlin + * // Transform 'scores' array by multiplying each score by 10 + * arrayTransform("scores", "score", multiply(variable("score"), 10)) + * ``` + * + * @param fieldName The name of field that contains array to transform. + * @param elementAlias The alias to use for the current element in the transform expression. + * @param transform The expression used to transform the elements. + * @return A new [Expression] representing the arrayTransform operation. + */ + @JvmStatic + fun arrayTransform(fieldName: String, elementAlias: String, transform: Expression): Expression = + FunctionExpression( + "array_transform", + notImplemented, + fieldName, + constant(elementAlias), + transform + ) + + /** + * Creates an expression that applies a provided transformation to each element in an array, + * providing the element's index to the transformation expression. + * + * ```kotlin + * // Transform 'scores' array by adding the index + * arrayTransformWithIndex(field("scores"), "score", "i", add(variable("score"), variable("i"))) + * ``` + * + * @param array The array expression to transform. + * @param elementAlias The alias to use for the current element in the transform expression. + * @param indexAlias The alias to use for the current index. + * @param transform The expression used to transform the elements. + * @return A new [Expression] representing the arrayTransform operation. + */ + @JvmStatic + fun arrayTransformWithIndex( + array: Expression, + elementAlias: String, + indexAlias: String, + transform: Expression + ): Expression = + FunctionExpression( + "array_transform", + notImplemented, + array, + constant(elementAlias), + constant(indexAlias), + transform + ) + + /** + * Creates an expression that applies a provided transformation to each element in an array, + * providing the element's index to the transformation expression. + * + * ```kotlin + * // Transform 'scores' array by adding the index + * arrayTransformWithIndex("scores", "score", "i", add(variable("score"), variable("i"))) + * ``` + * + * @param fieldName The name of field that contains array to transform. + * @param elementAlias The alias to use for the current element in the transform expression. + * @param indexAlias The alias to use for the current index. + * @param transform The expression used to transform the elements. + * @return A new [Expression] representing the arrayTransform operation. + */ + @JvmStatic + fun arrayTransformWithIndex( + fieldName: String, + elementAlias: String, + indexAlias: String, + transform: Expression + ): Expression = + FunctionExpression( + "array_transform", + notImplemented, + fieldName, + constant(elementAlias), + constant(indexAlias), + transform + ) + /** * Creates an expression that returns a slice of an [array] expression. * @@ -7746,6 +7854,38 @@ abstract class Expression internal constructor() { fun arrayFilter(alias: String, filter: BooleanExpression) = Companion.arrayFilter(this, alias, filter) + /** + * Creates an expression that applies a provided transformation to each element in an array. + * + * ```kotlin + * // Transform 'scores' array by multiplying each score by 10 + * field("scores").arrayTransform("score", multiply(variable("score"), 10)) + * ``` + * + * @param elementAlias The alias to use for the current element in the transform expression. + * @param transform The expression used to transform the elements. + * @return A new [Expression] representing the arrayTransform operation. + */ + fun arrayTransform(elementAlias: String, transform: Expression) = + Companion.arrayTransform(this, elementAlias, transform) + + /** + * Creates an expression that applies a provided transformation to each element in an array, + * providing the element's index to the transformation expression. + * + * ```kotlin + * // Transform 'scores' array by adding the index + * field("scores").arrayTransformWithIndex("score", "i", add(variable("score"), variable("i"))) + * ``` + * + * @param elementAlias The alias to use for the current element in the transform expression. + * @param indexAlias The alias to use for the current index. + * @param transform The expression used to transform the elements. + * @return A new [Expression] representing the arrayTransform operation. + */ + fun arrayTransformWithIndex(elementAlias: String, indexAlias: String, transform: Expression) = + Companion.arrayTransformWithIndex(this, elementAlias, indexAlias, transform) + /** * Creates an expression that returns a slice of this array expression. * From 13175f4e446efa7afb320dc1ef46bc8f5ce21ed6 Mon Sep 17 00:00:00 2001 From: Mila <107142260+milaGGL@users.noreply.github.com> Date: Mon, 30 Mar 2026 16:41:51 -0400 Subject: [PATCH 52/53] Update CHANGELOG.md --- firebase-firestore/CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/firebase-firestore/CHANGELOG.md b/firebase-firestore/CHANGELOG.md index 6408b0d41ad..00974d28450 100644 --- a/firebase-firestore/CHANGELOG.md +++ b/firebase-firestore/CHANGELOG.md @@ -1,5 +1,7 @@ # Unreleased +- [feature] Added support for Pipeline expressions `arraySlice`, `arrayFilter`, `arrayTransform` and `arrayTransformWithIndex`. + [#7989](https://github.com/firebase/firebase-android-sdk/pull/7989) - [feature] Added support for `timestampTruncate`, `timestampDiff`, and `timestampExtract` Pipeline expressions. [#7955](https://github.com/firebase/firebase-android-sdk/pull/7955) - [feature] Pipeline operations are GA now. From f072159598ac9b83e5037f92ada4cd131abf32bb Mon Sep 17 00:00:00 2001 From: Mila <107142260+milaGGL@users.noreply.github.com> Date: Tue, 31 Mar 2026 12:12:46 -0400 Subject: [PATCH 53/53] fix static, instance method collision --- firebase-firestore/CHANGELOG.md | 2 +- firebase-firestore/api.txt | 38 ++-- .../firebase/firestore/PipelineTest.java | 11 +- .../firestore/pipeline/expressions.kt | 174 ++++++++++++++---- 4 files changed, 173 insertions(+), 52 deletions(-) diff --git a/firebase-firestore/CHANGELOG.md b/firebase-firestore/CHANGELOG.md index ba03b7c2672..f559f300d0b 100644 --- a/firebase-firestore/CHANGELOG.md +++ b/firebase-firestore/CHANGELOG.md @@ -1,6 +1,6 @@ # Unreleased -- [feature] Added support for Pipeline expressions `arraySlice`, `arrayFilter`, `arrayTransform` and `arrayTransformWithIndex`. +- [feature] Added support for Pipeline expressions `arraySlice`, `arraySliceToEnd`, `arrayFilter`, `arrayTransform` and `arrayTransformWithIndex`. [#7989](https://github.com/firebase/firebase-android-sdk/pull/7989) - [feature] Added support for Pipeline expressions `ifNull` and `coalesce`. [#7976](https://github.com/firebase/firebase-android-sdk/pull/7976) diff --git a/firebase-firestore/api.txt b/firebase-firestore/api.txt index 75429dd932c..95e6f980ef9 100644 --- a/firebase-firestore/api.txt +++ b/firebase-firestore/api.txt @@ -907,21 +907,27 @@ package com.google.firebase.firestore.pipeline { method public final com.google.firebase.firestore.pipeline.Expression arrayReverse(); method public static final com.google.firebase.firestore.pipeline.Expression arrayReverse(com.google.firebase.firestore.pipeline.Expression array); method public static final com.google.firebase.firestore.pipeline.Expression arrayReverse(String arrayFieldName); - method public static final com.google.firebase.firestore.pipeline.Expression arraySlice(com.google.firebase.firestore.pipeline.Expression array, Object offset); - method public static final com.google.firebase.firestore.pipeline.Expression arraySlice(com.google.firebase.firestore.pipeline.Expression array, Object offset, Object length); - method public final com.google.firebase.firestore.pipeline.Expression arraySlice(Object offset); - method public final com.google.firebase.firestore.pipeline.Expression arraySlice(Object offset, Object length); - method public static final com.google.firebase.firestore.pipeline.Expression arraySlice(String arrayFieldName, Object offset); - method public static final com.google.firebase.firestore.pipeline.Expression arraySlice(String arrayFieldName, Object offset, Object length); + method public final com.google.firebase.firestore.pipeline.Expression arraySlice(com.google.firebase.firestore.pipeline.Expression offset, com.google.firebase.firestore.pipeline.Expression length); + method public static final com.google.firebase.firestore.pipeline.Expression arraySlice(com.google.firebase.firestore.pipeline.Expression array, com.google.firebase.firestore.pipeline.Expression offset, com.google.firebase.firestore.pipeline.Expression length); + method public static final com.google.firebase.firestore.pipeline.Expression arraySlice(com.google.firebase.firestore.pipeline.Expression array, int offset, int length); + method public final com.google.firebase.firestore.pipeline.Expression arraySlice(int offset, int length); + method public static final com.google.firebase.firestore.pipeline.Expression arraySlice(String arrayFieldName, com.google.firebase.firestore.pipeline.Expression offset, com.google.firebase.firestore.pipeline.Expression length); + method public static final com.google.firebase.firestore.pipeline.Expression arraySlice(String arrayFieldName, int offset, int length); + method public final com.google.firebase.firestore.pipeline.Expression arraySliceToEnd(com.google.firebase.firestore.pipeline.Expression offset); + method public static final com.google.firebase.firestore.pipeline.Expression arraySliceToEnd(com.google.firebase.firestore.pipeline.Expression array, com.google.firebase.firestore.pipeline.Expression offset); + method public static final com.google.firebase.firestore.pipeline.Expression arraySliceToEnd(com.google.firebase.firestore.pipeline.Expression array, int offset); + method public final com.google.firebase.firestore.pipeline.Expression arraySliceToEnd(int offset); + method public static final com.google.firebase.firestore.pipeline.Expression arraySliceToEnd(String arrayFieldName, com.google.firebase.firestore.pipeline.Expression offset); + method public static final com.google.firebase.firestore.pipeline.Expression arraySliceToEnd(String arrayFieldName, int offset); method public final com.google.firebase.firestore.pipeline.Expression arraySum(); method public static final com.google.firebase.firestore.pipeline.Expression arraySum(com.google.firebase.firestore.pipeline.Expression array); method public static final com.google.firebase.firestore.pipeline.Expression arraySum(String arrayFieldName); method public static final com.google.firebase.firestore.pipeline.Expression arrayTransform(com.google.firebase.firestore.pipeline.Expression array, String elementAlias, com.google.firebase.firestore.pipeline.Expression transform); method public final com.google.firebase.firestore.pipeline.Expression arrayTransform(String elementAlias, com.google.firebase.firestore.pipeline.Expression transform); - method public static final com.google.firebase.firestore.pipeline.Expression arrayTransform(String fieldName, String elementAlias, com.google.firebase.firestore.pipeline.Expression transform); + method public static final com.google.firebase.firestore.pipeline.Expression arrayTransform(String arrayFieldName, String elementAlias, com.google.firebase.firestore.pipeline.Expression transform); method public static final com.google.firebase.firestore.pipeline.Expression arrayTransformWithIndex(com.google.firebase.firestore.pipeline.Expression array, String elementAlias, String indexAlias, com.google.firebase.firestore.pipeline.Expression transform); method public final com.google.firebase.firestore.pipeline.Expression arrayTransformWithIndex(String elementAlias, String indexAlias, com.google.firebase.firestore.pipeline.Expression transform); - method public static final com.google.firebase.firestore.pipeline.Expression arrayTransformWithIndex(String fieldName, String elementAlias, String indexAlias, com.google.firebase.firestore.pipeline.Expression transform); + method public static final com.google.firebase.firestore.pipeline.Expression arrayTransformWithIndex(String arrayFieldName, String elementAlias, String indexAlias, com.google.firebase.firestore.pipeline.Expression transform); method public final com.google.firebase.firestore.pipeline.BooleanExpression asBoolean(); method public final com.google.firebase.firestore.pipeline.Ordering ascending(); method public final com.google.firebase.firestore.pipeline.AggregateFunction average(); @@ -1471,16 +1477,20 @@ package com.google.firebase.firestore.pipeline { method public com.google.firebase.firestore.pipeline.Expression arrayMinimumN(String arrayFieldName, int n); method public com.google.firebase.firestore.pipeline.Expression arrayReverse(com.google.firebase.firestore.pipeline.Expression array); method public com.google.firebase.firestore.pipeline.Expression arrayReverse(String arrayFieldName); - method public com.google.firebase.firestore.pipeline.Expression arraySlice(com.google.firebase.firestore.pipeline.Expression array, Object offset); - method public com.google.firebase.firestore.pipeline.Expression arraySlice(com.google.firebase.firestore.pipeline.Expression array, Object offset, Object length); - method public com.google.firebase.firestore.pipeline.Expression arraySlice(String arrayFieldName, Object offset); - method public com.google.firebase.firestore.pipeline.Expression arraySlice(String arrayFieldName, Object offset, Object length); + method public com.google.firebase.firestore.pipeline.Expression arraySlice(com.google.firebase.firestore.pipeline.Expression array, com.google.firebase.firestore.pipeline.Expression offset, com.google.firebase.firestore.pipeline.Expression length); + method public com.google.firebase.firestore.pipeline.Expression arraySlice(com.google.firebase.firestore.pipeline.Expression array, int offset, int length); + method public com.google.firebase.firestore.pipeline.Expression arraySlice(String arrayFieldName, com.google.firebase.firestore.pipeline.Expression offset, com.google.firebase.firestore.pipeline.Expression length); + method public com.google.firebase.firestore.pipeline.Expression arraySlice(String arrayFieldName, int offset, int length); + method public com.google.firebase.firestore.pipeline.Expression arraySliceToEnd(com.google.firebase.firestore.pipeline.Expression array, com.google.firebase.firestore.pipeline.Expression offset); + method public com.google.firebase.firestore.pipeline.Expression arraySliceToEnd(com.google.firebase.firestore.pipeline.Expression array, int offset); + method public com.google.firebase.firestore.pipeline.Expression arraySliceToEnd(String arrayFieldName, com.google.firebase.firestore.pipeline.Expression offset); + method public com.google.firebase.firestore.pipeline.Expression arraySliceToEnd(String arrayFieldName, int offset); method public com.google.firebase.firestore.pipeline.Expression arraySum(com.google.firebase.firestore.pipeline.Expression array); method public com.google.firebase.firestore.pipeline.Expression arraySum(String arrayFieldName); method public com.google.firebase.firestore.pipeline.Expression arrayTransform(com.google.firebase.firestore.pipeline.Expression array, String elementAlias, com.google.firebase.firestore.pipeline.Expression transform); - method public com.google.firebase.firestore.pipeline.Expression arrayTransform(String fieldName, String elementAlias, com.google.firebase.firestore.pipeline.Expression transform); + method public com.google.firebase.firestore.pipeline.Expression arrayTransform(String arrayFieldName, String elementAlias, com.google.firebase.firestore.pipeline.Expression transform); method public com.google.firebase.firestore.pipeline.Expression arrayTransformWithIndex(com.google.firebase.firestore.pipeline.Expression array, String elementAlias, String indexAlias, com.google.firebase.firestore.pipeline.Expression transform); - method public com.google.firebase.firestore.pipeline.Expression arrayTransformWithIndex(String fieldName, String elementAlias, String indexAlias, com.google.firebase.firestore.pipeline.Expression transform); + method public com.google.firebase.firestore.pipeline.Expression arrayTransformWithIndex(String arrayFieldName, String elementAlias, String indexAlias, com.google.firebase.firestore.pipeline.Expression transform); method public com.google.firebase.firestore.pipeline.Expression bitAnd(com.google.firebase.firestore.pipeline.Expression bits, byte[] bitsOther); method public com.google.firebase.firestore.pipeline.Expression bitAnd(com.google.firebase.firestore.pipeline.Expression bits, com.google.firebase.firestore.pipeline.Expression bitsOther); method public com.google.firebase.firestore.pipeline.Expression bitAnd(String bitsFieldName, byte[] bitsOther); diff --git a/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/PipelineTest.java b/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/PipelineTest.java index 6dd5a1db5b4..18f9b24eabb 100644 --- a/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/PipelineTest.java +++ b/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/PipelineTest.java @@ -33,6 +33,7 @@ import static com.google.firebase.firestore.pipeline.Expression.arrayMinimum; import static com.google.firebase.firestore.pipeline.Expression.arrayMinimumN; import static com.google.firebase.firestore.pipeline.Expression.arraySlice; +import static com.google.firebase.firestore.pipeline.Expression.arraySliceToEnd; import static com.google.firebase.firestore.pipeline.Expression.arrayTransform; import static com.google.firebase.firestore.pipeline.Expression.arrayTransformWithIndex; import static com.google.firebase.firestore.pipeline.Expression.collectionId; @@ -1110,14 +1111,14 @@ public void arraySliceWorks() { .where(equal("title", "The Lord of the Rings")) .select( arraySlice("tags", 1, 1).alias("staticMethodSlice"), - arraySlice("tags", 1).alias("staticMethodSliceToEnd"), + arraySliceToEnd("tags", 1).alias("staticMethodSliceToEnd"), field("tags").arraySlice(1, 1).alias("instanceMethodSlice"), - field("tags").arraySlice(1).alias("instanceMethodSliceToEnd"), + field("tags").arraySliceToEnd(1).alias("instanceMethodSliceToEnd"), field("tags").arraySlice(1, 10).alias("overflowLength"), field("tags").arraySlice(-1, 1).alias("negativeOffset"), - field("tags").arraySlice(-1).alias("negativeOffsetSliceToEnd"), - field("tags").arraySlice(10).alias("overflowOffset"), - field("tags").arraySlice(-10).alias("negativeOverflowOffset")) + field("tags").arraySliceToEnd(-1).alias("negativeOffsetSliceToEnd"), + field("tags").arraySliceToEnd(10).alias("overflowOffset"), + field("tags").arraySliceToEnd(-10).alias("negativeOverflowOffset")) .execute(); assertThat(waitFor(execute).getResults()) .comparingElementsUsing(DATA_CORRESPONDENCE) diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/expressions.kt b/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/expressions.kt index 37fbd13787e..d8f1a51fb8f 100644 --- a/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/expressions.kt +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/expressions.kt @@ -5625,17 +5625,21 @@ abstract class Expression internal constructor() { * arrayTransform("scores", "score", multiply(variable("score"), 10)) * ``` * - * @param fieldName The name of field that contains array to transform. + * @param arrayFieldName The name of field that contains array to transform. * @param elementAlias The alias to use for the current element in the transform expression. * @param transform The expression used to transform the elements. * @return A new [Expression] representing the arrayTransform operation. */ @JvmStatic - fun arrayTransform(fieldName: String, elementAlias: String, transform: Expression): Expression = + fun arrayTransform( + arrayFieldName: String, + elementAlias: String, + transform: Expression + ): Expression = FunctionExpression( "array_transform", notImplemented, - fieldName, + arrayFieldName, constant(elementAlias), transform ) @@ -5653,7 +5657,7 @@ abstract class Expression internal constructor() { * @param elementAlias The alias to use for the current element in the transform expression. * @param indexAlias The alias to use for the current index. * @param transform The expression used to transform the elements. - * @return A new [Expression] representing the arrayTransform operation. + * @return A new [Expression] representing the arrayTransformWithIndex operation. */ @JvmStatic fun arrayTransformWithIndex( @@ -5680,15 +5684,15 @@ abstract class Expression internal constructor() { * arrayTransformWithIndex("scores", "score", "i", add(variable("score"), variable("i"))) * ``` * - * @param fieldName The name of field that contains array to transform. + * @param arrayFieldName The name of field that contains array to transform. * @param elementAlias The alias to use for the current element in the transform expression. * @param indexAlias The alias to use for the current index. * @param transform The expression used to transform the elements. - * @return A new [Expression] representing the arrayTransform operation. + * @return A new [Expression] representing the arrayTransformWithIndex operation. */ @JvmStatic fun arrayTransformWithIndex( - fieldName: String, + arrayFieldName: String, elementAlias: String, indexAlias: String, transform: Expression @@ -5696,12 +5700,76 @@ abstract class Expression internal constructor() { FunctionExpression( "array_transform", notImplemented, - fieldName, + arrayFieldName, constant(elementAlias), constant(indexAlias), transform ) + /** + * Creates an expression that returns a slice of an [array] expression to its end. + * + * ```kotlin + * // Get elements from the 'items' array starting from index 2 + * arraySliceToEnd(field("items"), 2) + * ``` + * + * @param array The array expression. + * @param offset The starting index. + * @return A new [Expression] representing the arraySliceToEnd operation. + */ + @JvmStatic + fun arraySliceToEnd(array: Expression, offset: Int): Expression = + FunctionExpression("array_slice", notImplemented, array, toExprOrConstant(offset)) + + /** + * Creates an expression that returns a slice of an [array] expression to its end. + * + * ```kotlin + * // Get elements from the 'items' array starting at an offset defined by a field + * arraySliceToEnd(field("items"), field("startIdx")) + * ``` + * + * @param array The array expression. + * @param offset The starting index. + * @return A new [Expression] representing the arraySliceToEnd operation. + */ + @JvmStatic + fun arraySliceToEnd(array: Expression, offset: Expression): Expression = + FunctionExpression("array_slice", notImplemented, array, toExprOrConstant(offset)) + + /** + * Creates an expression that returns a slice of an array field to its end. + * + * ```kotlin + * // Get elements from the 'items' array starting from index 2 + * arraySliceToEnd("items", 2) + * ``` + * + * @param arrayFieldName The name of field that contains the array. + * @param offset The starting index. + * @return A new [Expression] representing the arraySliceToEnd operation. + */ + @JvmStatic + fun arraySliceToEnd(arrayFieldName: String, offset: Int): Expression = + FunctionExpression("array_slice", notImplemented, arrayFieldName, toExprOrConstant(offset)) + + /** + * Creates an expression that returns a slice of an array field to its end. + * + * ```kotlin + * // Get elements from the 'items' array starting at an offset defined by a field + * arraySliceToEnd("items", field("startIdx")) + * ``` + * + * @param arrayFieldName The name of field that contains the array. + * @param offset The starting index. + * @return A new [Expression] representing the arraySliceToEnd operation. + */ + @JvmStatic + fun arraySliceToEnd(arrayFieldName: String, offset: Expression): Expression = + FunctionExpression("array_slice", notImplemented, arrayFieldName, toExprOrConstant(offset)) + /** * Creates an expression that returns a slice of an [array] expression. * @@ -5716,7 +5784,7 @@ abstract class Expression internal constructor() { * @return A new [Expression] representing the arraySlice operation. */ @JvmStatic - fun arraySlice(array: Expression, offset: Any, length: Any): Expression = + fun arraySlice(array: Expression, offset: Int, length: Int): Expression = FunctionExpression( "array_slice", notImplemented, @@ -5726,59 +5794,73 @@ abstract class Expression internal constructor() { ) /** - * Creates an expression that returns a slice of an [array] expression to its end. + * Creates an expression that returns a slice of an array field. * * ```kotlin - * // Get elements from the 'items' array starting from index 2 - * arraySlice(field("items"), 2) + * // Get 5 elements from the 'items' array starting from index 2 + * arraySlice("items", 2, 5) * ``` * - * @param array The array expression. + * @param arrayFieldName The name of field that contains the array. * @param offset The starting index. + * @param length The number of elements to return. * @return A new [Expression] representing the arraySlice operation. */ @JvmStatic - fun arraySlice(array: Expression, offset: Any): Expression = - FunctionExpression("array_slice", notImplemented, array, toExprOrConstant(offset)) + fun arraySlice(arrayFieldName: String, offset: Int, length: Int): Expression = + FunctionExpression( + "array_slice", + notImplemented, + arrayFieldName, + toExprOrConstant(offset), + toExprOrConstant(length) + ) /** - * Creates an expression that returns a slice of an array field. + * Creates an expression that returns a slice of an [array] expression. * * ```kotlin - * // Get 5 elements from the 'items' array starting from index 2 - * arraySlice("items", 2, 5) + * // Get elements from the 'items' array using expressions for offset and length + * arraySlice(field("items"), field("startIdx"), field("length")) * ``` * - * @param arrayFieldName The name of field that contains the array. + * @param array The array expression. * @param offset The starting index. * @param length The number of elements to return. * @return A new [Expression] representing the arraySlice operation. */ @JvmStatic - fun arraySlice(arrayFieldName: String, offset: Any, length: Any): Expression = + fun arraySlice(array: Expression, offset: Expression, length: Expression): Expression = FunctionExpression( "array_slice", notImplemented, - arrayFieldName, + array, toExprOrConstant(offset), toExprOrConstant(length) ) /** - * Creates an expression that returns a slice of an array field to its end. + * Creates an expression that returns a slice of an array field. * * ```kotlin - * // Get elements from the 'items' array starting from index 2 - * arraySlice("items", 2) + * // Get elements from the 'items' array using expressions for offset and length + * arraySlice("items", field("startIdx"), field("length")) * ``` * * @param arrayFieldName The name of field that contains the array. * @param offset The starting index. + * @param length The number of elements to return. * @return A new [Expression] representing the arraySlice operation. */ @JvmStatic - fun arraySlice(arrayFieldName: String, offset: Any): Expression = - FunctionExpression("array_slice", notImplemented, arrayFieldName, toExprOrConstant(offset)) + fun arraySlice(arrayFieldName: String, offset: Expression, length: Expression): Expression = + FunctionExpression( + "array_slice", + notImplemented, + arrayFieldName, + toExprOrConstant(offset), + toExprOrConstant(length) + ) /** * Creates an expression that returns the sum of the elements in an array. @@ -9040,11 +9122,37 @@ abstract class Expression internal constructor() { * @param elementAlias The alias to use for the current element in the transform expression. * @param indexAlias The alias to use for the current index. * @param transform The expression used to transform the elements. - * @return A new [Expression] representing the arrayTransform operation. + * @return A new [Expression] representing the arrayTransformWithIndex operation. */ fun arrayTransformWithIndex(elementAlias: String, indexAlias: String, transform: Expression) = Companion.arrayTransformWithIndex(this, elementAlias, indexAlias, transform) + /** + * Creates an expression that returns a slice of this array expression to its end. + * + * ```kotlin + * // Get elements from the 'items' array starting from index 2 + * field("items").arraySliceToEnd(2) + * ``` + * + * @param offset The starting index. + * @return A new [Expression] representing the arraySliceToEnd operation. + */ + fun arraySliceToEnd(offset: Int) = Companion.arraySliceToEnd(this, offset) + + /** + * Creates an expression that returns a slice of this array expression to its end. + * + * ```kotlin + * // Get elements from the 'items' array starting from the value of the 'offset' field + * field("items").arraySliceToEnd(field("offset")) + * ``` + * + * @param offset The starting index. + * @return A new [Expression] representing the arraySliceToEnd operation. + */ + fun arraySliceToEnd(offset: Expression) = Companion.arraySliceToEnd(this, offset) + /** * Creates an expression that returns a slice of this array expression. * @@ -9057,20 +9165,22 @@ abstract class Expression internal constructor() { * @param length The number of elements to return. * @return A new [Expression] representing the arraySlice operation. */ - fun arraySlice(offset: Any, length: Any) = Companion.arraySlice(this, offset, length) + fun arraySlice(offset: Int, length: Int) = Companion.arraySlice(this, offset, length) /** - * Creates an expression that returns a slice of this array expression to its end. + * Creates an expression that returns a slice of this array expression. * * ```kotlin - * // Get elements from the 'items' array starting from index 2 - * field("items").arraySlice(2) + * // Get elements from the 'items' array using expressions for offset and length + * field("items").arraySlice(field("offset"), field("length")) * ``` * * @param offset The starting index. + * @param length The number of elements to return. * @return A new [Expression] representing the arraySlice operation. */ - fun arraySlice(offset: Any) = Companion.arraySlice(this, offset) + fun arraySlice(offset: Expression, length: Expression) = + Companion.arraySlice(this, offset, length) /** * Creates an expression that returns the sum of the elements in this array expression.