From 2110f365a742db25f20f360a770cb63db2eed3cd Mon Sep 17 00:00:00 2001 From: wu-hui Date: Tue, 5 Aug 2025 16:40:33 -0400 Subject: [PATCH] serverTimestamp and integration tests --- firebase-firestore/api.txt | 238 +- firebase-firestore/firebase-firestore.gradle | 2 +- .../firebase/firestore/AggregationTest.java | 8 +- .../firebase/firestore/ConformanceTest.java | 2 + .../firebase/firestore/PipelineTest.java | 34 +- .../firestore/QueryToPipelineTest.java | 2 + .../firestore/RealtimePipelineTest.kt | 1948 +++++++++++++++++ .../testutil/IntegrationTestUtil.java | 2 +- .../firebase/firestore/DocumentChange.java | 87 - .../com/google/firebase/firestore/Pipeline.kt | 230 +- .../firebase/firestore/QuerySnapshot.java | 14 +- .../firebase/firestore/RealtimePipeline.kt | 569 +++++ .../firebase/firestore/core/EventManager.java | 4 + .../firebase/firestore/core/PipelineUtil.kt | 12 +- .../google/firebase/firestore/core/Query.java | 9 +- .../firestore/core/QueryListener.java | 8 +- .../firebase/firestore/local/LocalStore.java | 3 + .../firebase/firestore/local/TargetData.java | 12 + .../firestore/pipeline/EvaluateResult.kt | 1 + .../firebase/firestore/pipeline/evaluation.kt | 140 +- .../firestore/pipeline/expressions.kt | 242 +- .../firebase/firestore/pipeline/stage.kt | 4 +- .../firestore/remote/RemoteSerializer.java | 5 +- .../firestore/DocumentChangeTest.java | 14 +- .../firestore/local/QueryEngineTestCase.java | 7 +- .../firestore/pipeline/ArithmeticTests.kt | 790 +++++++ .../pipeline/MirroringSemanticsTests.kt | 14 + .../firestore/pipeline/StringTests.kt | 271 +++ .../firebase/firestore/pipeline/testUtil.kt | 2 +- 29 files changed, 4219 insertions(+), 455 deletions(-) create mode 100644 firebase-firestore/src/androidTest/java/com/google/firebase/firestore/RealtimePipelineTest.kt create mode 100644 firebase-firestore/src/main/java/com/google/firebase/firestore/RealtimePipeline.kt diff --git a/firebase-firestore/api.txt b/firebase-firestore/api.txt index e3ac8fecd5a..d3f073e974c 100644 --- a/firebase-firestore/api.txt +++ b/firebase-firestore/api.txt @@ -1,10 +1,6 @@ // Signature format: 3.0 package com.google.firebase.firestore { - public class AbstractPipeline { - method protected final com.google.android.gms.tasks.Task execute(com.google.firebase.firestore.pipeline.InternalOptions? options); - } - public abstract class AggregateField { method public static com.google.firebase.firestore.AggregateField.AverageAggregateField average(com.google.firebase.firestore.FieldPath); method public static com.google.firebase.firestore.AggregateField.AverageAggregateField average(String); @@ -211,6 +207,7 @@ package com.google.firebase.firestore { method public com.google.firebase.firestore.LoadBundleTask loadBundle(java.io.InputStream); method public com.google.firebase.firestore.LoadBundleTask loadBundle(java.nio.ByteBuffer); method public com.google.firebase.firestore.PipelineSource pipeline(); + method public com.google.firebase.firestore.RealtimePipelineSource realtimePipeline(); method public com.google.android.gms.tasks.Task runBatch(com.google.firebase.firestore.WriteBatch.Function); method public com.google.android.gms.tasks.Task runTransaction(com.google.firebase.firestore.Transaction.Function); method public com.google.android.gms.tasks.Task runTransaction(com.google.firebase.firestore.TransactionOptions, com.google.firebase.firestore.Transaction.Function); @@ -423,14 +420,14 @@ package com.google.firebase.firestore { method public com.google.firebase.firestore.PersistentCacheSettings.Builder setSizeBytes(long); } - public final class Pipeline extends com.google.firebase.firestore.AbstractPipeline { + public final class Pipeline { method public com.google.firebase.firestore.Pipeline addFields(com.google.firebase.firestore.pipeline.Selectable field, com.google.firebase.firestore.pipeline.Selectable... additionalFields); 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.AggregateWithAlias accumulator, com.google.firebase.firestore.pipeline.AggregateWithAlias... additionalAccumulators); 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(); - method public com.google.android.gms.tasks.Task execute(com.google.firebase.firestore.pipeline.RealtimePipelineOptions options); + method public com.google.android.gms.tasks.Task execute(com.google.firebase.firestore.pipeline.InternalOptions? options); method public com.google.firebase.firestore.Pipeline findNearest(com.google.firebase.firestore.pipeline.Field vectorField, com.google.firebase.firestore.VectorValue vectorValue, com.google.firebase.firestore.pipeline.FindNearestStage.DistanceMeasure distanceMeasure); method public com.google.firebase.firestore.Pipeline findNearest(com.google.firebase.firestore.pipeline.Field vectorField, double[] vectorValue, com.google.firebase.firestore.pipeline.FindNearestStage.DistanceMeasure distanceMeasure); method public com.google.firebase.firestore.Pipeline findNearest(com.google.firebase.firestore.pipeline.FindNearestStage stage); @@ -468,6 +465,32 @@ package com.google.firebase.firestore { property public final com.google.firebase.Timestamp? updateTime; } + public final class PipelineResultChange { + method public com.google.firebase.firestore.PipelineResult component1(); + method public com.google.firebase.firestore.PipelineResultChange.ChangeType component2(); + method public Integer? component3(); + method public Integer? component4(); + method public com.google.firebase.firestore.PipelineResultChange copy(com.google.firebase.firestore.PipelineResult result, com.google.firebase.firestore.PipelineResultChange.ChangeType type, Integer? oldIndex, Integer? newIndex); + method public Integer? getNewIndex(); + method public Integer? getOldIndex(); + method public com.google.firebase.firestore.PipelineResult getResult(); + method public com.google.firebase.firestore.PipelineResultChange.ChangeType getType(); + property public final Integer? newIndex; + property public final Integer? oldIndex; + property public final com.google.firebase.firestore.PipelineResult result; + property public final com.google.firebase.firestore.PipelineResultChange.ChangeType type; + field public static final com.google.firebase.firestore.PipelineResultChange.Companion Companion; + } + + public enum PipelineResultChange.ChangeType { + enum_constant public static final com.google.firebase.firestore.PipelineResultChange.ChangeType ADDED; + enum_constant public static final com.google.firebase.firestore.PipelineResultChange.ChangeType MODIFIED; + enum_constant public static final com.google.firebase.firestore.PipelineResultChange.ChangeType REMOVED; + } + + public static final class PipelineResultChange.Companion { + } + public final class PipelineSnapshot implements java.lang.Iterable kotlin.jvm.internal.markers.KMappedMarker { method public com.google.firebase.Timestamp getExecutionTime(); method public java.util.List getResults(); @@ -476,6 +499,16 @@ package com.google.firebase.firestore { property public final java.util.List results; } + public final class PipelineSnapshotMetadata { + method public boolean component1(); + method public boolean component2(); + method public com.google.firebase.firestore.PipelineSnapshotMetadata copy(boolean hasPendingWrites, boolean isConsistentBetweenListeners); + method public boolean getHasPendingWrites(); + method public boolean isConsistentBetweenListeners(); + property public final boolean hasPendingWrites; + property public final boolean isConsistentBetweenListeners; + } + public final class PipelineSource { method public com.google.firebase.firestore.Pipeline collection(com.google.firebase.firestore.CollectionReference ref); method public com.google.firebase.firestore.Pipeline collection(com.google.firebase.firestore.pipeline.CollectionSource stage); @@ -486,7 +519,6 @@ package com.google.firebase.firestore { method public com.google.firebase.firestore.Pipeline database(); method public com.google.firebase.firestore.Pipeline documents(com.google.firebase.firestore.DocumentReference... documents); method public com.google.firebase.firestore.Pipeline documents(java.lang.String... documents); - method public com.google.firebase.firestore.Pipeline pipeline(com.google.firebase.firestore.pipeline.CollectionGroupSource stage); } @java.lang.annotation.Retention(java.lang.annotation.RetentionPolicy.RUNTIME) @java.lang.annotation.Target({java.lang.annotation.ElementType.METHOD, java.lang.annotation.ElementType.FIELD}) public @interface PropertyName { @@ -564,23 +596,49 @@ package com.google.firebase.firestore { method public java.util.List toObjects(Class, com.google.firebase.firestore.DocumentSnapshot.ServerTimestampBehavior); } - public final class RealtimePipeline extends com.google.firebase.firestore.AbstractPipeline { - method public com.google.android.gms.tasks.Task execute(); - method public com.google.android.gms.tasks.Task execute(com.google.firebase.firestore.pipeline.PipelineOptions options); + public final class RealtimePipeline { + method public com.google.firebase.firestore.ListenerRegistration addSnapshotListener(com.google.firebase.firestore.EventListener listener); + method public com.google.firebase.firestore.ListenerRegistration addSnapshotListener(com.google.firebase.firestore.RealtimePipelineOptions options, com.google.firebase.firestore.EventListener listener); + method public com.google.firebase.firestore.ListenerRegistration addSnapshotListener(java.util.concurrent.Executor executor, com.google.firebase.firestore.EventListener listener); + method public com.google.firebase.firestore.ListenerRegistration addSnapshotListener(java.util.concurrent.Executor executor, com.google.firebase.firestore.RealtimePipelineOptions options, com.google.firebase.firestore.EventListener listener); + method public String canonicalId(); method public com.google.firebase.firestore.RealtimePipeline limit(int limit); method public com.google.firebase.firestore.RealtimePipeline offset(int offset); method public com.google.firebase.firestore.RealtimePipeline select(com.google.firebase.firestore.pipeline.Selectable selection, java.lang.Object... additionalSelections); method public com.google.firebase.firestore.RealtimePipeline select(String fieldName, java.lang.Object... additionalSelections); + method public kotlinx.coroutines.flow.Flow snapshots(); + method public kotlinx.coroutines.flow.Flow snapshots(com.google.firebase.firestore.RealtimePipelineOptions options); method public com.google.firebase.firestore.RealtimePipeline sort(com.google.firebase.firestore.pipeline.Ordering order, com.google.firebase.firestore.pipeline.Ordering... additionalOrders); method public com.google.firebase.firestore.RealtimePipeline where(com.google.firebase.firestore.pipeline.BooleanExpr condition); } + public final class RealtimePipelineOptions { + ctor public RealtimePipelineOptions(); + method public com.google.firebase.firestore.RealtimePipelineOptions withMetadataChanges(com.google.firebase.firestore.MetadataChanges metadataChanges); + method public com.google.firebase.firestore.RealtimePipelineOptions withServerTimestampBehavior(com.google.firebase.firestore.DocumentSnapshot.ServerTimestampBehavior serverTimestampBehavior); + method public com.google.firebase.firestore.RealtimePipelineOptions withSource(com.google.firebase.firestore.ListenSource source); + field public static final com.google.firebase.firestore.RealtimePipelineOptions.Companion Companion; + field public static final com.google.firebase.firestore.RealtimePipelineOptions DEFAULT; + } + + public static final class RealtimePipelineOptions.Companion { + } + + public final class RealtimePipelineSnapshot { + method public java.util.List getChanges(com.google.firebase.firestore.MetadataChanges? metadataChanges = null); + method public com.google.firebase.firestore.PipelineSnapshotMetadata getMetadata(); + method public java.util.List getResults(); + property public final com.google.firebase.firestore.PipelineSnapshotMetadata metadata; + property public final java.util.List results; + } + public final class RealtimePipelineSource { method public com.google.firebase.firestore.RealtimePipeline collection(com.google.firebase.firestore.CollectionReference ref); method public com.google.firebase.firestore.RealtimePipeline collection(com.google.firebase.firestore.pipeline.CollectionSource stage); method public com.google.firebase.firestore.RealtimePipeline collection(String path); + method public com.google.firebase.firestore.RealtimePipeline collectionGroup(com.google.firebase.firestore.pipeline.CollectionGroupSource stage); method public com.google.firebase.firestore.RealtimePipeline collectionGroup(String collectionId); - method public com.google.firebase.firestore.RealtimePipeline pipeline(com.google.firebase.firestore.pipeline.CollectionGroupSource stage); + method public com.google.firebase.firestore.RealtimePipeline convertFrom(com.google.firebase.firestore.Query query); } @java.lang.annotation.Retention(java.lang.annotation.RetentionPolicy.RUNTIME) @java.lang.annotation.Target({java.lang.annotation.ElementType.METHOD, java.lang.annotation.ElementType.FIELD}) public @interface ServerTimestamp { @@ -703,8 +761,8 @@ package com.google.firebase.firestore.pipeline { public abstract class AbstractOptions> { method public final T with(String key, boolean value); method public final T with(String key, com.google.firebase.firestore.pipeline.Field value); - method public final T with(String key, com.google.firebase.firestore.pipeline.GenericOptions value); method protected final T with(String key, com.google.firebase.firestore.pipeline.InternalOptions value); + method public final T with(String key, com.google.firebase.firestore.pipeline.RawOptions value); method public final T with(String key, double value); method protected final T with(String key, error.NonExistentClass value); method public final T with(String key, String value); @@ -774,8 +832,12 @@ package com.google.firebase.firestore.pipeline { } public final class CollectionGroupSource extends com.google.firebase.firestore.pipeline.Stage { + ctor public CollectionGroupSource(String collectionId, com.google.firebase.firestore.pipeline.InternalOptions options); + method public String canonicalId(); + method public String getCollectionId(); method public static com.google.firebase.firestore.pipeline.CollectionGroupSource of(String collectionId); method public error.NonExistentClass withForceIndex(String value); + property public final String collectionId; field public static final com.google.firebase.firestore.pipeline.CollectionGroupSource.Companion Companion; } @@ -784,78 +846,24 @@ package com.google.firebase.firestore.pipeline { } public final class CollectionSource extends com.google.firebase.firestore.pipeline.Stage { - method public static com.google.firebase.firestore.pipeline.CollectionSource of(com.google.firebase.firestore.CollectionReference ref); - method public static com.google.firebase.firestore.pipeline.CollectionSource of(String path); + method public String canonicalId(); method public error.NonExistentClass withForceIndex(String value); field public static final com.google.firebase.firestore.pipeline.CollectionSource.Companion Companion; } public static final class CollectionSource.Companion { - method public com.google.firebase.firestore.pipeline.CollectionSource of(com.google.firebase.firestore.CollectionReference ref); - method public com.google.firebase.firestore.pipeline.CollectionSource of(String path); - } - - public final class ExplainOptions extends com.google.firebase.firestore.pipeline.AbstractOptions { - method public error.NonExistentClass withIndexRecommendation(boolean value); - method public error.NonExistentClass withMode(com.google.firebase.firestore.pipeline.ExplainOptions.ExplainMode value); - method public error.NonExistentClass withOutputFormat(com.google.firebase.firestore.pipeline.ExplainOptions.OutputFormat value); - method public error.NonExistentClass withProfiles(com.google.firebase.firestore.pipeline.ExplainOptions.Profiles value); - method public error.NonExistentClass withRedact(boolean value); - method public error.NonExistentClass withVerbosity(com.google.firebase.firestore.pipeline.ExplainOptions.Verbosity value); - field public static final com.google.firebase.firestore.pipeline.ExplainOptions.Companion Companion; - field public static final com.google.firebase.firestore.pipeline.ExplainOptions DEFAULT; - } - - public static final class ExplainOptions.Companion { - } - - public static final class ExplainOptions.ExplainMode { - field public static final com.google.firebase.firestore.pipeline.ExplainOptions.ExplainMode ANALYZE; - field public static final com.google.firebase.firestore.pipeline.ExplainOptions.ExplainMode.Companion Companion; - field public static final com.google.firebase.firestore.pipeline.ExplainOptions.ExplainMode EXECUTE; - field public static final com.google.firebase.firestore.pipeline.ExplainOptions.ExplainMode EXPLAIN; - } - - public static final class ExplainOptions.ExplainMode.Companion { - } - - public static final class ExplainOptions.OutputFormat { - field public static final com.google.firebase.firestore.pipeline.ExplainOptions.OutputFormat.Companion Companion; - field public static final com.google.firebase.firestore.pipeline.ExplainOptions.OutputFormat JSON; - field public static final com.google.firebase.firestore.pipeline.ExplainOptions.OutputFormat STRUCT; - field public static final com.google.firebase.firestore.pipeline.ExplainOptions.OutputFormat TEXT; - } - - public static final class ExplainOptions.OutputFormat.Companion { - } - - public static final class ExplainOptions.Profiles { - field public static final com.google.firebase.firestore.pipeline.ExplainOptions.Profiles BYTES_THROUGHPUT; - field public static final com.google.firebase.firestore.pipeline.ExplainOptions.Profiles.Companion Companion; - field public static final com.google.firebase.firestore.pipeline.ExplainOptions.Profiles LATENCY; - field public static final com.google.firebase.firestore.pipeline.ExplainOptions.Profiles RECORDS_COUNT; - } - - public static final class ExplainOptions.Profiles.Companion { - } - - public static final class ExplainOptions.Verbosity { - field public static final com.google.firebase.firestore.pipeline.ExplainOptions.Verbosity.Companion Companion; - field public static final com.google.firebase.firestore.pipeline.ExplainOptions.Verbosity EXECUTION_TREE; - field public static final com.google.firebase.firestore.pipeline.ExplainOptions.Verbosity SUMMARY_ONLY; - } - - public static final class ExplainOptions.Verbosity.Companion { } public abstract class Expr { + method public static final com.google.firebase.firestore.pipeline.Expr abs(com.google.firebase.firestore.pipeline.Expr numericExpr); + method public static final com.google.firebase.firestore.pipeline.Expr abs(String numericField); method public final com.google.firebase.firestore.pipeline.Expr add(com.google.firebase.firestore.pipeline.Expr second); method public static final com.google.firebase.firestore.pipeline.Expr add(com.google.firebase.firestore.pipeline.Expr first, com.google.firebase.firestore.pipeline.Expr second); method public static final com.google.firebase.firestore.pipeline.Expr add(com.google.firebase.firestore.pipeline.Expr first, Number second); method public final com.google.firebase.firestore.pipeline.Expr add(Number second); method public static final com.google.firebase.firestore.pipeline.Expr add(String numericFieldName, com.google.firebase.firestore.pipeline.Expr second); method public static final com.google.firebase.firestore.pipeline.Expr add(String numericFieldName, Number second); - method public com.google.firebase.firestore.pipeline.ExprWithAlias alias(String alias); + method public com.google.firebase.firestore.pipeline.Selectable alias(String alias); method public static final com.google.firebase.firestore.pipeline.BooleanExpr and(com.google.firebase.firestore.pipeline.BooleanExpr condition, com.google.firebase.firestore.pipeline.BooleanExpr... conditions); method public static final com.google.firebase.firestore.pipeline.Expr array(java.lang.Object?... elements); method public static final com.google.firebase.firestore.pipeline.Expr array(java.util.List elements); @@ -1012,6 +1020,8 @@ package com.google.firebase.firestore.pipeline { method public final com.google.firebase.firestore.pipeline.BooleanExpr exists(); method public static final com.google.firebase.firestore.pipeline.BooleanExpr exists(com.google.firebase.firestore.pipeline.Expr value); method public static final com.google.firebase.firestore.pipeline.BooleanExpr exists(String fieldName); + method public static final com.google.firebase.firestore.pipeline.Expr exp(com.google.firebase.firestore.pipeline.Expr numericExpr); + method public static final com.google.firebase.firestore.pipeline.Expr exp(String numericField); method public static final com.google.firebase.firestore.pipeline.Field field(com.google.firebase.firestore.FieldPath fieldPath); method public static final com.google.firebase.firestore.pipeline.Field field(String name); method public final com.google.firebase.firestore.pipeline.Expr floor(); @@ -1058,6 +1068,14 @@ package com.google.firebase.firestore.pipeline { method public final com.google.firebase.firestore.pipeline.BooleanExpr like(String pattern); method public static final com.google.firebase.firestore.pipeline.BooleanExpr like(String fieldName, com.google.firebase.firestore.pipeline.Expr pattern); method public static final com.google.firebase.firestore.pipeline.BooleanExpr like(String fieldName, String pattern); + method public static final com.google.firebase.firestore.pipeline.Expr ln(com.google.firebase.firestore.pipeline.Expr numericExpr); + method public static final com.google.firebase.firestore.pipeline.Expr ln(String numericField); + method public static final com.google.firebase.firestore.pipeline.Expr log(com.google.firebase.firestore.pipeline.Expr numericExpr, com.google.firebase.firestore.pipeline.Expr base); + method public static final com.google.firebase.firestore.pipeline.Expr log(com.google.firebase.firestore.pipeline.Expr numericExpr, Number base); + method public static final com.google.firebase.firestore.pipeline.Expr log(String numericField, com.google.firebase.firestore.pipeline.Expr base); + method public static final com.google.firebase.firestore.pipeline.Expr log(String numericField, Number base); + method public static final com.google.firebase.firestore.pipeline.Expr log10(com.google.firebase.firestore.pipeline.Expr numericExpr); + method public static final com.google.firebase.firestore.pipeline.Expr log10(String numericField); method public static final com.google.firebase.firestore.pipeline.Expr logicalMaximum(com.google.firebase.firestore.pipeline.Expr expr, java.lang.Object... others); method public final com.google.firebase.firestore.pipeline.Expr logicalMaximum(com.google.firebase.firestore.pipeline.Expr... others); method public final com.google.firebase.firestore.pipeline.Expr logicalMaximum(java.lang.Object... others); @@ -1079,6 +1097,7 @@ package com.google.firebase.firestore.pipeline { method public static final com.google.firebase.firestore.pipeline.BooleanExpr lte(String fieldName, com.google.firebase.firestore.pipeline.Expr expression); method public static final com.google.firebase.firestore.pipeline.BooleanExpr lte(String fieldName, Object value); method public static final com.google.firebase.firestore.pipeline.Expr map(java.util.Map elements); + method public final com.google.firebase.firestore.pipeline.Expr mapGet(com.google.firebase.firestore.pipeline.Expr keyExpression); method public static final com.google.firebase.firestore.pipeline.Expr mapGet(com.google.firebase.firestore.pipeline.Expr mapExpression, com.google.firebase.firestore.pipeline.Expr keyExpression); method public static final com.google.firebase.firestore.pipeline.Expr mapGet(com.google.firebase.firestore.pipeline.Expr mapExpression, String key); method public final com.google.firebase.firestore.pipeline.Expr mapGet(String key); @@ -1087,7 +1106,7 @@ package com.google.firebase.firestore.pipeline { method public static final com.google.firebase.firestore.pipeline.Expr mapMerge(com.google.firebase.firestore.pipeline.Expr firstMap, com.google.firebase.firestore.pipeline.Expr secondMap, com.google.firebase.firestore.pipeline.Expr... otherMaps); method public final com.google.firebase.firestore.pipeline.Expr mapMerge(com.google.firebase.firestore.pipeline.Expr mapExpr, com.google.firebase.firestore.pipeline.Expr... otherMaps); method public static final com.google.firebase.firestore.pipeline.Expr mapMerge(String firstMapFieldName, com.google.firebase.firestore.pipeline.Expr secondMap, com.google.firebase.firestore.pipeline.Expr... otherMaps); - method public final com.google.firebase.firestore.pipeline.Expr mapRemove(com.google.firebase.firestore.pipeline.Expr key); + method public final com.google.firebase.firestore.pipeline.Expr mapRemove(com.google.firebase.firestore.pipeline.Expr keyExpression); method public static final com.google.firebase.firestore.pipeline.Expr mapRemove(com.google.firebase.firestore.pipeline.Expr mapExpr, com.google.firebase.firestore.pipeline.Expr key); method public static final com.google.firebase.firestore.pipeline.Expr mapRemove(com.google.firebase.firestore.pipeline.Expr mapExpr, String key); method public final com.google.firebase.firestore.pipeline.Expr mapRemove(String key); @@ -1187,6 +1206,10 @@ package com.google.firebase.firestore.pipeline { method public final com.google.firebase.firestore.pipeline.BooleanExpr strContains(String substring); method public static final com.google.firebase.firestore.pipeline.BooleanExpr strContains(String fieldName, com.google.firebase.firestore.pipeline.Expr substring); method public static final com.google.firebase.firestore.pipeline.BooleanExpr strContains(String fieldName, String substring); + method public final com.google.firebase.firestore.pipeline.Expr substr(com.google.firebase.firestore.pipeline.Expr start, com.google.firebase.firestore.pipeline.Expr length); + method public static final com.google.firebase.firestore.pipeline.Expr substr(com.google.firebase.firestore.pipeline.Expr stringExpression, com.google.firebase.firestore.pipeline.Expr index, com.google.firebase.firestore.pipeline.Expr length); + method public final com.google.firebase.firestore.pipeline.Expr substr(int start, int length); + method public static final com.google.firebase.firestore.pipeline.Expr substr(String fieldName, int index, int length); method public final com.google.firebase.firestore.pipeline.Expr subtract(com.google.firebase.firestore.pipeline.Expr subtrahend); method public static final com.google.firebase.firestore.pipeline.Expr subtract(com.google.firebase.firestore.pipeline.Expr minuend, com.google.firebase.firestore.pipeline.Expr subtrahend); method public static final com.google.firebase.firestore.pipeline.Expr subtract(com.google.firebase.firestore.pipeline.Expr minuend, Number subtrahend); @@ -1196,16 +1219,16 @@ package com.google.firebase.firestore.pipeline { method public final com.google.firebase.firestore.pipeline.AggregateFunction sum(); method public final com.google.firebase.firestore.pipeline.Expr timestampAdd(com.google.firebase.firestore.pipeline.Expr unit, com.google.firebase.firestore.pipeline.Expr amount); method public static final com.google.firebase.firestore.pipeline.Expr timestampAdd(com.google.firebase.firestore.pipeline.Expr timestamp, com.google.firebase.firestore.pipeline.Expr unit, com.google.firebase.firestore.pipeline.Expr amount); - method public static final com.google.firebase.firestore.pipeline.Expr timestampAdd(com.google.firebase.firestore.pipeline.Expr timestamp, String unit, double amount); + method public static final com.google.firebase.firestore.pipeline.Expr timestampAdd(com.google.firebase.firestore.pipeline.Expr timestamp, String unit, long amount); method public static final com.google.firebase.firestore.pipeline.Expr timestampAdd(String fieldName, com.google.firebase.firestore.pipeline.Expr unit, com.google.firebase.firestore.pipeline.Expr amount); - method public final com.google.firebase.firestore.pipeline.Expr timestampAdd(String unit, double amount); - method public static final com.google.firebase.firestore.pipeline.Expr timestampAdd(String fieldName, String unit, double amount); + method public static final com.google.firebase.firestore.pipeline.Expr timestampAdd(String fieldName, String unit, long amount); + method public final com.google.firebase.firestore.pipeline.Expr timestampAdd(String unit, long amount); method public final com.google.firebase.firestore.pipeline.Expr timestampSub(com.google.firebase.firestore.pipeline.Expr unit, com.google.firebase.firestore.pipeline.Expr amount); method public static final com.google.firebase.firestore.pipeline.Expr timestampSub(com.google.firebase.firestore.pipeline.Expr timestamp, com.google.firebase.firestore.pipeline.Expr unit, com.google.firebase.firestore.pipeline.Expr amount); - method public static final com.google.firebase.firestore.pipeline.Expr timestampSub(com.google.firebase.firestore.pipeline.Expr timestamp, String unit, double amount); + method public static final com.google.firebase.firestore.pipeline.Expr timestampSub(com.google.firebase.firestore.pipeline.Expr timestamp, String unit, long amount); method public static final com.google.firebase.firestore.pipeline.Expr timestampSub(String fieldName, com.google.firebase.firestore.pipeline.Expr unit, com.google.firebase.firestore.pipeline.Expr amount); - method public final com.google.firebase.firestore.pipeline.Expr timestampSub(String unit, double amount); - method public static final com.google.firebase.firestore.pipeline.Expr timestampSub(String fieldName, String unit, double amount); + method public static final com.google.firebase.firestore.pipeline.Expr timestampSub(String fieldName, String unit, long amount); + method public final com.google.firebase.firestore.pipeline.Expr timestampSub(String unit, long amount); method public final com.google.firebase.firestore.pipeline.Expr timestampToUnixMicros(); method public static final com.google.firebase.firestore.pipeline.Expr timestampToUnixMicros(com.google.firebase.firestore.pipeline.Expr expr); method public static final com.google.firebase.firestore.pipeline.Expr timestampToUnixMicros(String fieldName); @@ -1243,6 +1266,8 @@ package com.google.firebase.firestore.pipeline { } public static final class Expr.Companion { + method public com.google.firebase.firestore.pipeline.Expr abs(com.google.firebase.firestore.pipeline.Expr numericExpr); + method public com.google.firebase.firestore.pipeline.Expr abs(String numericField); method public com.google.firebase.firestore.pipeline.Expr add(com.google.firebase.firestore.pipeline.Expr first, com.google.firebase.firestore.pipeline.Expr second); method public com.google.firebase.firestore.pipeline.Expr add(com.google.firebase.firestore.pipeline.Expr first, Number second); method public com.google.firebase.firestore.pipeline.Expr add(String numericFieldName, com.google.firebase.firestore.pipeline.Expr second); @@ -1354,6 +1379,8 @@ package com.google.firebase.firestore.pipeline { method public com.google.firebase.firestore.pipeline.Expr euclideanDistance(String vectorFieldName, double[] vector); method public com.google.firebase.firestore.pipeline.BooleanExpr exists(com.google.firebase.firestore.pipeline.Expr value); method public com.google.firebase.firestore.pipeline.BooleanExpr exists(String fieldName); + method public com.google.firebase.firestore.pipeline.Expr exp(com.google.firebase.firestore.pipeline.Expr numericExpr); + method public com.google.firebase.firestore.pipeline.Expr exp(String numericField); method public com.google.firebase.firestore.pipeline.Field field(com.google.firebase.firestore.FieldPath fieldPath); method public com.google.firebase.firestore.pipeline.Field field(String name); method public com.google.firebase.firestore.pipeline.Expr floor(com.google.firebase.firestore.pipeline.Expr numericExpr); @@ -1385,6 +1412,14 @@ package com.google.firebase.firestore.pipeline { method public com.google.firebase.firestore.pipeline.BooleanExpr like(com.google.firebase.firestore.pipeline.Expr stringExpression, String pattern); method public com.google.firebase.firestore.pipeline.BooleanExpr like(String fieldName, com.google.firebase.firestore.pipeline.Expr pattern); method public com.google.firebase.firestore.pipeline.BooleanExpr like(String fieldName, String pattern); + method public com.google.firebase.firestore.pipeline.Expr ln(com.google.firebase.firestore.pipeline.Expr numericExpr); + method public com.google.firebase.firestore.pipeline.Expr ln(String numericField); + method public com.google.firebase.firestore.pipeline.Expr log(com.google.firebase.firestore.pipeline.Expr numericExpr, com.google.firebase.firestore.pipeline.Expr base); + method public com.google.firebase.firestore.pipeline.Expr log(com.google.firebase.firestore.pipeline.Expr numericExpr, Number base); + method public com.google.firebase.firestore.pipeline.Expr log(String numericField, com.google.firebase.firestore.pipeline.Expr base); + method public com.google.firebase.firestore.pipeline.Expr log(String numericField, Number base); + method public com.google.firebase.firestore.pipeline.Expr log10(com.google.firebase.firestore.pipeline.Expr numericExpr); + method public com.google.firebase.firestore.pipeline.Expr log10(String numericField); method public com.google.firebase.firestore.pipeline.Expr logicalMaximum(com.google.firebase.firestore.pipeline.Expr expr, java.lang.Object... others); method public com.google.firebase.firestore.pipeline.Expr logicalMaximum(String fieldName, java.lang.Object... others); method public com.google.firebase.firestore.pipeline.Expr logicalMinimum(com.google.firebase.firestore.pipeline.Expr expr, java.lang.Object... others); @@ -1470,18 +1505,20 @@ package com.google.firebase.firestore.pipeline { method public com.google.firebase.firestore.pipeline.BooleanExpr strContains(com.google.firebase.firestore.pipeline.Expr stringExpression, String substring); method public com.google.firebase.firestore.pipeline.BooleanExpr strContains(String fieldName, com.google.firebase.firestore.pipeline.Expr substring); method public com.google.firebase.firestore.pipeline.BooleanExpr strContains(String fieldName, String substring); + method public com.google.firebase.firestore.pipeline.Expr substr(com.google.firebase.firestore.pipeline.Expr stringExpression, com.google.firebase.firestore.pipeline.Expr index, com.google.firebase.firestore.pipeline.Expr length); + method public com.google.firebase.firestore.pipeline.Expr substr(String fieldName, int index, int length); method public com.google.firebase.firestore.pipeline.Expr subtract(com.google.firebase.firestore.pipeline.Expr minuend, com.google.firebase.firestore.pipeline.Expr subtrahend); method public com.google.firebase.firestore.pipeline.Expr subtract(com.google.firebase.firestore.pipeline.Expr minuend, Number subtrahend); method public com.google.firebase.firestore.pipeline.Expr subtract(String numericFieldName, com.google.firebase.firestore.pipeline.Expr subtrahend); method public com.google.firebase.firestore.pipeline.Expr subtract(String numericFieldName, Number subtrahend); method public com.google.firebase.firestore.pipeline.Expr timestampAdd(com.google.firebase.firestore.pipeline.Expr timestamp, com.google.firebase.firestore.pipeline.Expr unit, com.google.firebase.firestore.pipeline.Expr amount); - method public com.google.firebase.firestore.pipeline.Expr timestampAdd(com.google.firebase.firestore.pipeline.Expr timestamp, String unit, double amount); + method public com.google.firebase.firestore.pipeline.Expr timestampAdd(com.google.firebase.firestore.pipeline.Expr timestamp, String unit, long amount); method public com.google.firebase.firestore.pipeline.Expr timestampAdd(String fieldName, com.google.firebase.firestore.pipeline.Expr unit, com.google.firebase.firestore.pipeline.Expr amount); - method public com.google.firebase.firestore.pipeline.Expr timestampAdd(String fieldName, String unit, double amount); + method public com.google.firebase.firestore.pipeline.Expr timestampAdd(String fieldName, String unit, long amount); method public com.google.firebase.firestore.pipeline.Expr timestampSub(com.google.firebase.firestore.pipeline.Expr timestamp, com.google.firebase.firestore.pipeline.Expr unit, com.google.firebase.firestore.pipeline.Expr amount); - method public com.google.firebase.firestore.pipeline.Expr timestampSub(com.google.firebase.firestore.pipeline.Expr timestamp, String unit, double amount); + method public com.google.firebase.firestore.pipeline.Expr timestampSub(com.google.firebase.firestore.pipeline.Expr timestamp, String unit, long amount); method public com.google.firebase.firestore.pipeline.Expr timestampSub(String fieldName, com.google.firebase.firestore.pipeline.Expr unit, com.google.firebase.firestore.pipeline.Expr amount); - method public com.google.firebase.firestore.pipeline.Expr timestampSub(String fieldName, String unit, double amount); + method public com.google.firebase.firestore.pipeline.Expr timestampSub(String fieldName, String unit, long amount); method public com.google.firebase.firestore.pipeline.Expr timestampToUnixMicros(com.google.firebase.firestore.pipeline.Expr expr); method public com.google.firebase.firestore.pipeline.Expr timestampToUnixMicros(String fieldName); method public com.google.firebase.firestore.pipeline.Expr timestampToUnixMillis(com.google.firebase.firestore.pipeline.Expr expr); @@ -1508,9 +1545,15 @@ package com.google.firebase.firestore.pipeline { } public final class ExprWithAlias extends com.google.firebase.firestore.pipeline.Selectable { + method public String canonicalId(); + method public String getAlias(); + method public com.google.firebase.firestore.pipeline.Expr getExpr(); + property public String alias; + property public com.google.firebase.firestore.pipeline.Expr expr; } public final class Field extends com.google.firebase.firestore.pipeline.Selectable { + method public String canonicalId(); field public static final com.google.firebase.firestore.pipeline.Field.Companion Companion; field public static final com.google.firebase.firestore.pipeline.Field DOCUMENT_ID; } @@ -1547,14 +1590,7 @@ package com.google.firebase.firestore.pipeline { } public class FunctionExpr extends com.google.firebase.firestore.pipeline.Expr { - } - - public final class GenericOptions extends com.google.firebase.firestore.pipeline.AbstractOptions { - field public static final com.google.firebase.firestore.pipeline.GenericOptions.Companion Companion; - field public static final com.google.firebase.firestore.pipeline.GenericOptions DEFAULT; - } - - public static final class GenericOptions.Companion { + method public String canonicalId(); } public final class InternalOptions { @@ -1569,6 +1605,7 @@ package com.google.firebase.firestore.pipeline { public final class Ordering { method public static com.google.firebase.firestore.pipeline.Ordering ascending(com.google.firebase.firestore.pipeline.Expr expr); method public static com.google.firebase.firestore.pipeline.Ordering ascending(String fieldName); + method public String canonicalId(); method public static com.google.firebase.firestore.pipeline.Ordering descending(com.google.firebase.firestore.pipeline.Expr expr); method public static com.google.firebase.firestore.pipeline.Ordering descending(String fieldName); method public com.google.firebase.firestore.pipeline.Expr getExpr(); @@ -1585,7 +1622,6 @@ package com.google.firebase.firestore.pipeline { } public final class PipelineOptions extends com.google.firebase.firestore.pipeline.AbstractOptions { - method public com.google.firebase.firestore.pipeline.PipelineOptions withExplainOptions(com.google.firebase.firestore.pipeline.ExplainOptions options); method public com.google.firebase.firestore.pipeline.PipelineOptions withIndexMode(com.google.firebase.firestore.pipeline.PipelineOptions.IndexMode indexMode); field public static final com.google.firebase.firestore.pipeline.PipelineOptions.Companion Companion; field public static final com.google.firebase.firestore.pipeline.PipelineOptions DEFAULT; @@ -1602,6 +1638,14 @@ package com.google.firebase.firestore.pipeline { public static final class PipelineOptions.IndexMode.Companion { } + public final class RawOptions extends com.google.firebase.firestore.pipeline.AbstractOptions { + field public static final com.google.firebase.firestore.pipeline.RawOptions.Companion Companion; + field public static final com.google.firebase.firestore.pipeline.RawOptions DEFAULT; + } + + public static final class RawOptions.Companion { + } + 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); @@ -1642,14 +1686,12 @@ package com.google.firebase.firestore.pipeline { } public abstract sealed class Stage> { - method protected final String getName(); - method public final T with(String key, boolean value); - method public final T with(String key, com.google.firebase.firestore.pipeline.Field value); - method public final T with(String key, double value); - method protected final T with(String key, error.NonExistentClass value); - method public final T with(String key, String value); - method public final T with(String key, long value); - property protected final String name; + 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); + method protected final T withOption(String key, error.NonExistentClass value); + method public final T withOption(String key, String value); + method public final T withOption(String key, long value); } public final class UnnestStage extends com.google.firebase.firestore.pipeline.Stage { diff --git a/firebase-firestore/firebase-firestore.gradle b/firebase-firestore/firebase-firestore.gradle index 7a3871fb5a8..72704cc31dc 100644 --- a/firebase-firestore/firebase-firestore.gradle +++ b/firebase-firestore/firebase-firestore.gradle @@ -75,7 +75,7 @@ android { def targetBackend = findProperty("targetBackend") ?: "emulator" buildConfigField("String", "TARGET_BACKEND", "\"$targetBackend\"") - def targetDatabaseId = findProperty('targetDatabaseId') ?: "(default)" + def targetDatabaseId = findProperty('targetDatabaseId') ?: "enterprise" buildConfigField("String", "TARGET_DATABASE_ID", "\"$targetDatabaseId\"") def localProps = new Properties() diff --git a/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/AggregationTest.java b/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/AggregationTest.java index 48cbf19402e..448f6b01493 100644 --- a/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/AggregationTest.java +++ b/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/AggregationTest.java @@ -400,8 +400,12 @@ public void testCannotPerformMoreThanMaxAggregations() { assertThat(e, instanceOf(FirebaseFirestoreException.class)); FirebaseFirestoreException firestoreException = (FirebaseFirestoreException) e; - assertEquals(FirebaseFirestoreException.Code.INVALID_ARGUMENT, firestoreException.getCode()); - assertTrue(firestoreException.getMessage().contains("maximum number of aggregations")); + if (isRunningAgainstEmulator()) { + assertEquals(FirebaseFirestoreException.Code.UNKNOWN, firestoreException.getCode()); + } else { + assertEquals(FirebaseFirestoreException.Code.INVALID_ARGUMENT, firestoreException.getCode()); + assertTrue(firestoreException.getMessage().contains("maximum number of aggregations")); + } } @Test diff --git a/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/ConformanceTest.java b/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/ConformanceTest.java index ecaa153358d..896ff0a2ec9 100644 --- a/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/ConformanceTest.java +++ b/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/ConformanceTest.java @@ -48,6 +48,7 @@ import java.util.stream.Collectors; import org.junit.AfterClass; import org.junit.BeforeClass; +import org.junit.Ignore; import org.junit.Test; import org.junit.runner.RunWith; import org.junit.runners.Parameterized; @@ -61,6 +62,7 @@ * com.google.firebase.firestore.conformance}) were modified to support the Android SDK. */ @RunWith(Parameterized.class) +@Ignore public class ConformanceTest { private static FirebaseFirestore firestore; private static TestCaseIgnoreList testCaseIgnoreList; 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 9ac1e01f249..28f0252a803 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 @@ -19,6 +19,7 @@ import static com.google.firebase.firestore.pipeline.Expr.and; import static com.google.firebase.firestore.pipeline.Expr.arrayContains; import static com.google.firebase.firestore.pipeline.Expr.arrayContainsAny; +import static com.google.firebase.firestore.pipeline.Expr.constant; import static com.google.firebase.firestore.pipeline.Expr.cosineDistance; import static com.google.firebase.firestore.pipeline.Expr.endsWith; import static com.google.firebase.firestore.pipeline.Expr.eq; @@ -37,6 +38,7 @@ import static com.google.firebase.firestore.pipeline.Expr.subtract; import static com.google.firebase.firestore.pipeline.Expr.vector; import static com.google.firebase.firestore.pipeline.Ordering.ascending; +import static com.google.firebase.firestore.testutil.IntegrationTestUtil.isRunningAgainstEmulator; import static com.google.firebase.firestore.testutil.IntegrationTestUtil.waitFor; import androidx.test.ext.junit.runners.AndroidJUnit4; @@ -47,6 +49,7 @@ import com.google.firebase.firestore.pipeline.AggregateFunction; import com.google.firebase.firestore.pipeline.AggregateStage; import com.google.firebase.firestore.pipeline.Expr; +import com.google.firebase.firestore.pipeline.Field; import com.google.firebase.firestore.pipeline.RawStage; import com.google.firebase.firestore.testutil.IntegrationTestUtil; import java.util.Collections; @@ -367,13 +370,14 @@ public void whereWithOr() { .collection(randomCol) .where(or(eq("genre", "Romance"), eq("genre", "Dystopian"))) .select("title") + .sort(field("title").ascending()) .execute(); assertThat(waitFor(execute).getResults()) .comparingElementsUsing(DATA_CORRESPONDENCE) .containsExactly( + ImmutableMap.of("title", "1984"), ImmutableMap.of("title", "Pride and Prejudice"), - ImmutableMap.of("title", "The Handmaid's Tale"), - ImmutableMap.of("title", "1984")); + ImmutableMap.of("title", "The Handmaid's Tale")); } @Test @@ -418,6 +422,7 @@ public void arrayContainsAnyWorks() { .collection(randomCol) .where(arrayContainsAny("tags", ImmutableList.of("comedy", "classic"))) .select("title") + .sort(field("title").descending()) .execute(); assertThat(waitFor(execute).getResults()) .comparingElementsUsing(DATA_CORRESPONDENCE) @@ -480,7 +485,8 @@ public void testStrConcat() { firestore .pipeline() .collection(randomCol) - .select(field("author").strConcat(" - ", field("title")).alias("bookInfo")) + .sort(ascending(Field.DOCUMENT_ID)) + .select(strConcat(field("author"), constant(" - "), field("title")).alias("bookInfo")) .limit(1) .execute(); assertThat(waitFor(execute).getResults()) @@ -545,12 +551,12 @@ public void testLength() { } @Test - @Ignore("Not supported yet") public void testToLowercase() { Task execute = firestore .pipeline() .collection(randomCol) + .sort(Field.DOCUMENT_ID.ascending()) .select(field("title").toLower().alias("lowercaseTitle")) .limit(1) .execute(); @@ -560,12 +566,12 @@ public void testToLowercase() { } @Test - @Ignore("Not supported yet") public void testToUppercase() { Task execute = firestore .pipeline() .collection(randomCol) + .sort(Field.DOCUMENT_ID.ascending()) .select(field("author").toUpper().alias("uppercaseAuthor")) .limit(1) .execute(); @@ -597,6 +603,10 @@ public void testTrim() { @Test public void testLike() { + if (isRunningAgainstEmulator()) { + return; + } + Task execute = firestore .pipeline() @@ -611,6 +621,10 @@ public void testLike() { @Test public void testRegexContains() { + if (isRunningAgainstEmulator()) { + return; + } + Task execute = firestore .pipeline() @@ -622,6 +636,10 @@ public void testRegexContains() { @Test public void testRegexMatches() { + if (isRunningAgainstEmulator()) { + return; + } + Task execute = firestore .pipeline() @@ -637,12 +655,13 @@ public void testArithmeticOperations() { firestore .pipeline() .collection(randomCol) + .sort(ascending(Field.DOCUMENT_ID)) + .limit(1) .select( add(field("rating"), 1).alias("ratingPlusOne"), subtract(field("published"), 1900).alias("yearsSince1900"), field("rating").multiply(10).alias("ratingTimesTen"), field("rating").divide(2).alias("ratingDividedByTwo")) - .limit(1) .execute(); assertThat(waitFor(execute).getResults()) .comparingElementsUsing(DATA_CORRESPONDENCE) @@ -749,6 +768,7 @@ public void testMapGet() { firestore .pipeline() .collection(randomCol) + .sort(field("title").descending()) .select(field("awards").mapGet("hugo").alias("hugoAward"), field("title")) .where(eq("hugoAward", true)) .execute(); @@ -790,6 +810,7 @@ public void testNestedFields() { .collection(randomCol) .where(eq("awards.hugo", true)) .select("title", "awards.hugo") + .sort(field("title").descending()) .execute(); assertThat(waitFor(execute).getResults()) .comparingElementsUsing(DATA_CORRESPONDENCE) @@ -805,6 +826,7 @@ public void testMapGetWithFieldNameIncludingNotation() { .pipeline() .collection(randomCol) .where(eq("awards.hugo", true)) + .sort(field("title").descending()) .select( "title", field("nestedField.level.1"), 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 93f54924ec9..8b40a1b98ea 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 @@ -28,6 +28,8 @@ import static com.google.firebase.firestore.testutil.IntegrationTestUtil.testCollectionWithDocs; import static com.google.firebase.firestore.testutil.IntegrationTestUtil.testFirestore; import static com.google.firebase.firestore.testutil.IntegrationTestUtil.waitFor; +import static com.google.firebase.firestore.testutil.TestUtil.expectError; +import static com.google.firebase.firestore.testutil.TestUtil.map; import static java.util.Arrays.asList; import static java.util.Collections.singletonList; import static org.junit.Assert.assertEquals; 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 new file mode 100644 index 00000000000..16f145e0497 --- /dev/null +++ b/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/RealtimePipelineTest.kt @@ -0,0 +1,1948 @@ +// 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 androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import com.google.firebase.Timestamp +import com.google.firebase.firestore.pipeline.Expr.Companion.abs +import com.google.firebase.firestore.pipeline.Expr.Companion.add +import com.google.firebase.firestore.pipeline.Expr.Companion.and +import com.google.firebase.firestore.pipeline.Expr.Companion.arrayContains +import com.google.firebase.firestore.pipeline.Expr.Companion.arrayContainsAny +import com.google.firebase.firestore.pipeline.Expr.Companion.arrayLength +import com.google.firebase.firestore.pipeline.Expr.Companion.byteLength +import com.google.firebase.firestore.pipeline.Expr.Companion.ceil +import com.google.firebase.firestore.pipeline.Expr.Companion.charLength +import com.google.firebase.firestore.pipeline.Expr.Companion.constant +import com.google.firebase.firestore.pipeline.Expr.Companion.divide +import com.google.firebase.firestore.pipeline.Expr.Companion.endsWith +import com.google.firebase.firestore.pipeline.Expr.Companion.eqAny +import com.google.firebase.firestore.pipeline.Expr.Companion.exists +import com.google.firebase.firestore.pipeline.Expr.Companion.exp +import com.google.firebase.firestore.pipeline.Expr.Companion.field +import com.google.firebase.firestore.pipeline.Expr.Companion.floor +import com.google.firebase.firestore.pipeline.Expr.Companion.isAbsent +import com.google.firebase.firestore.pipeline.Expr.Companion.isNan +import com.google.firebase.firestore.pipeline.Expr.Companion.isNotNan +import com.google.firebase.firestore.pipeline.Expr.Companion.isNotNull +import com.google.firebase.firestore.pipeline.Expr.Companion.isNull +import com.google.firebase.firestore.pipeline.Expr.Companion.like +import com.google.firebase.firestore.pipeline.Expr.Companion.ln +import com.google.firebase.firestore.pipeline.Expr.Companion.log +import com.google.firebase.firestore.pipeline.Expr.Companion.log10 +import com.google.firebase.firestore.pipeline.Expr.Companion.mod +import com.google.firebase.firestore.pipeline.Expr.Companion.multiply +import com.google.firebase.firestore.pipeline.Expr.Companion.not +import com.google.firebase.firestore.pipeline.Expr.Companion.notEqAny +import com.google.firebase.firestore.pipeline.Expr.Companion.or +import com.google.firebase.firestore.pipeline.Expr.Companion.pow +import com.google.firebase.firestore.pipeline.Expr.Companion.regexContains +import com.google.firebase.firestore.pipeline.Expr.Companion.regexMatch +import com.google.firebase.firestore.pipeline.Expr.Companion.reverse +import com.google.firebase.firestore.pipeline.Expr.Companion.round +import com.google.firebase.firestore.pipeline.Expr.Companion.sqrt +import com.google.firebase.firestore.pipeline.Expr.Companion.startsWith +import com.google.firebase.firestore.pipeline.Expr.Companion.strConcat +import com.google.firebase.firestore.pipeline.Expr.Companion.strContains +import com.google.firebase.firestore.pipeline.Expr.Companion.subtract +import com.google.firebase.firestore.pipeline.Expr.Companion.timestampAdd +import com.google.firebase.firestore.pipeline.Expr.Companion.timestampToUnixMicros +import com.google.firebase.firestore.pipeline.Expr.Companion.timestampToUnixMillis +import com.google.firebase.firestore.pipeline.Expr.Companion.timestampToUnixSeconds +import com.google.firebase.firestore.pipeline.Expr.Companion.toLower +import com.google.firebase.firestore.pipeline.Expr.Companion.toUpper +import com.google.firebase.firestore.pipeline.Expr.Companion.trim +import com.google.firebase.firestore.pipeline.Expr.Companion.unixMicrosToTimestamp +import com.google.firebase.firestore.pipeline.Expr.Companion.unixMillisToTimestamp +import com.google.firebase.firestore.pipeline.Expr.Companion.unixSecondsToTimestamp +import com.google.firebase.firestore.pipeline.Expr.Companion.xor +import com.google.firebase.firestore.pipeline.Ordering.Companion.ascending +import com.google.firebase.firestore.testutil.IntegrationTestUtil +import com.google.firebase.firestore.testutil.IntegrationTestUtil.waitFor +import com.google.firebase.firestore.testutil.IntegrationTestUtil.writeAllDocs +import com.google.firebase.firestore.util.Util.autoId +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.tasks.await +import kotlinx.coroutines.withTimeoutOrNull +import org.junit.After +import org.junit.Before +import org.junit.Ignore +import org.junit.Test +import org.junit.runner.RunWith + +private val bookDocs: Map> = + 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), + "nestedField" to mapOf("level.1" to mapOf("level.2" to true)), + ), + "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), + ), + "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), + ), + "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), + ), + "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), + ), + "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), + ), + "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), + ), + "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), + ), + "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), + ), + "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), + ), + ) + +private val eventDocs: Map> = + mapOf( + "event1" to + mapOf( + "name" to "Test Event", + "timestamp" to Timestamp(1698228000, 0), // 2023-10-26T10:00:00Z + "unix_seconds" to 1698228000L, + "unix_millis" to 1698228000000L, + "unix_micros" to 1698228000000000L + ) + ) + +@RunWith(AndroidJUnit4::class) +class RealtimePipelineTest { + private lateinit var db: FirebaseFirestore + private lateinit var collRef: CollectionReference + private lateinit var eventCollRef: CollectionReference + + @Before + fun setUp() { + collRef = IntegrationTestUtil.testCollection() + db = collRef.firestore + eventCollRef = db.collection(autoId()) + + writeAllDocs(collRef, bookDocs) + writeAllDocs(eventCollRef, eventDocs) + } + + @After + fun tearDown() { + IntegrationTestUtil.tearDown() + } + + @Test + fun testBasicAsyncStream() = runBlocking { + val pipeline = db.realtimePipeline().collection(collRef.path).where(field("rating").gte(4.5)) + + val channel = Channel(Channel.UNLIMITED) + val job = launch { pipeline.snapshots().collect { snapshot -> channel.send(snapshot) } } + + val firstSnapshot = channel.receive() + assertThat(firstSnapshot.metadata.isConsistentBetweenListeners).isFalse() + assertThat(firstSnapshot.results).hasSize(3) + assertThat(firstSnapshot.results[0].get("title")).isEqualTo("Dune") + assertThat(firstSnapshot.results[1].get("title")).isEqualTo("Pride and Prejudice") + assertThat(firstSnapshot.results[2].get("title")).isEqualTo("The Lord of the Rings") + + // dropping Dune out of the result set + collRef.document("book10").update("rating", 4.4).await() + val secondSnapshot = channel.receive() + assertThat(secondSnapshot.results).hasSize(2) + assertThat(secondSnapshot.results[0].get("title")).isEqualTo("Pride and Prejudice") + assertThat(secondSnapshot.results[1].get("title")).isEqualTo("The Lord of the Rings") + + // Adding book1 to the result + collRef.document("book1").update("rating", 4.7).await() + val thirdSnapshot = channel.receive() + assertThat(thirdSnapshot.results).hasSize(3) + assertThat(thirdSnapshot.results[0].get("title")) + .isEqualTo("The Hitchhiker's Guide to the Galaxy") + + // Deleting book2 + collRef.document("book2").delete().await() + val fourthSnapshot = channel.receive() + assertThat(fourthSnapshot.results).hasSize(2) + assertThat(fourthSnapshot.results[0].get("title")) + .isEqualTo("The Hitchhiker's Guide to the Galaxy") + assertThat(fourthSnapshot.results[1].get("title")).isEqualTo("The Lord of the Rings") + + job.cancel() + } + + @Test + fun testResultChanges() = runBlocking { + val pipeline = db.realtimePipeline().collection(collRef.path).where(field("rating").gte(4.5)) + + val channel = Channel(Channel.UNLIMITED) + val job = launch { pipeline.snapshots().collect { snapshot -> channel.send(snapshot) } } + + val firstSnapshot = channel.receive() + assertThat(firstSnapshot.getChanges()).hasSize(3) + assertThat(firstSnapshot.getChanges()[0].result.get("title")).isEqualTo("Dune") + assertThat(firstSnapshot.getChanges()[0].type).isEqualTo(PipelineResultChange.ChangeType.ADDED) + assertThat(firstSnapshot.getChanges()[1].result.get("title")).isEqualTo("Pride and Prejudice") + assertThat(firstSnapshot.getChanges()[1].type).isEqualTo(PipelineResultChange.ChangeType.ADDED) + assertThat(firstSnapshot.getChanges()[2].result.get("title")).isEqualTo("The Lord of the Rings") + assertThat(firstSnapshot.getChanges()[2].type).isEqualTo(PipelineResultChange.ChangeType.ADDED) + + // dropping Dune out of the result set + collRef.document("book10").update("rating", 4.4).await() + val secondSnapshot = channel.receive() + assertThat(secondSnapshot.getChanges()).hasSize(1) + assertThat(secondSnapshot.getChanges()[0].result.get("title")).isEqualTo("Dune") + assertThat(secondSnapshot.getChanges()[0].type) + .isEqualTo(PipelineResultChange.ChangeType.REMOVED) + assertThat(secondSnapshot.getChanges()[0].oldIndex).isEqualTo(0) + assertThat(secondSnapshot.getChanges()[0].newIndex).isEqualTo(-1) + + // Adding book1 to the result + collRef.document("book1").update("rating", 4.7).await() + val thirdSnapshot = channel.receive() + assertThat(thirdSnapshot.getChanges()).hasSize(1) + assertThat(thirdSnapshot.getChanges()[0].result.get("title")) + .isEqualTo("The Hitchhiker's Guide to the Galaxy") + assertThat(thirdSnapshot.getChanges()[0].type).isEqualTo(PipelineResultChange.ChangeType.ADDED) + assertThat(thirdSnapshot.getChanges()[0].oldIndex).isEqualTo(-1) + assertThat(thirdSnapshot.getChanges()[0].newIndex).isEqualTo(0) + + // Delete book 2 + collRef.document("book2").delete().await() + val fourthSnapshot = channel.receive() + assertThat(fourthSnapshot.getChanges()).hasSize(1) + assertThat(fourthSnapshot.getChanges()[0].result.get("title")).isEqualTo("Pride and Prejudice") + assertThat(fourthSnapshot.getChanges()[0].oldIndex).isEqualTo(1) + assertThat(fourthSnapshot.getChanges()[0].newIndex).isEqualTo(-1) + + job.cancel() + } + + @Test + fun testCanListenToCache() = runBlocking { + val pipeline = db.realtimePipeline().collection(collRef.path).where(field("rating").gte(4.5)) + val options = + RealtimePipelineOptions() + .withMetadataChanges(MetadataChanges.INCLUDE) + .withSource(ListenSource.CACHE) + val channel = Channel(Channel.UNLIMITED) + val job = launch { pipeline.snapshots(options).collect { snapshot -> channel.send(snapshot) } } + + val firstSnapshot = channel.receive() + assertThat(firstSnapshot.metadata.isConsistentBetweenListeners).isFalse() + assertThat(firstSnapshot.results).hasSize(3) + assertThat(firstSnapshot.results[0].get("title")).isEqualTo("Dune") + assertThat(firstSnapshot.results[1].get("title")).isEqualTo("Pride and Prejudice") + assertThat(firstSnapshot.results[2].get("title")).isEqualTo("The Lord of the Rings") + + waitFor(db.disableNetwork()) + waitFor(db.enableNetwork()) + + val nextSnapshot = withTimeoutOrNull(100) { channel.receive() } + assertThat(nextSnapshot).isNull() + + job.cancel() + } + + @Test + fun testCanListenToMetadataOnlyChanges() = runBlocking { + val pipeline = db.realtimePipeline().collection(collRef.path).where(field("rating").gte(4.5)) + + val options = RealtimePipelineOptions().withMetadataChanges(MetadataChanges.INCLUDE) + val channel = Channel(Channel.UNLIMITED) + val job = launch { pipeline.snapshots(options).collect { snapshot -> channel.send(snapshot) } } + + val firstSnapshot = channel.receive() + assertThat(firstSnapshot.metadata.isConsistentBetweenListeners).isFalse() + assertThat(firstSnapshot.results).hasSize(3) + assertThat(firstSnapshot.results[0].get("title")).isEqualTo("Dune") + assertThat(firstSnapshot.results[1].get("title")).isEqualTo("Pride and Prejudice") + assertThat(firstSnapshot.results[2].get("title")).isEqualTo("The Lord of the Rings") + + val secondSnapshot = channel.receive() + assertThat(secondSnapshot.metadata.isConsistentBetweenListeners).isTrue() + assertThat(secondSnapshot.results).hasSize(3) + assertThat(secondSnapshot.getChanges()).isEmpty() + + job.cancel() + } + + @Test + fun testCanReadServerTimestampEstimateProperly() = runBlocking { + waitFor(db.disableNetwork()) + collRef.document("book1").update("rating", FieldValue.serverTimestamp()) + + val pipeline = + db + .realtimePipeline() + .collection(collRef.path) + .where(field("title").eq("The Hitchhiker's Guide to the Galaxy")) + + val options = + RealtimePipelineOptions() + .withServerTimestampBehavior(DocumentSnapshot.ServerTimestampBehavior.ESTIMATE) + + val channel = Channel(Channel.UNLIMITED) + val job = launch { pipeline.snapshots(options).collect { snapshot -> channel.send(snapshot) } } + + val firstSnapshot = channel.receive() + val result = firstSnapshot.results[0] + assertThat(firstSnapshot.metadata.isConsistentBetweenListeners).isFalse() + assertThat(result.get("rating")).isInstanceOf(Timestamp::class.java) + assertThat(result.get("rating")).isEqualTo(result.getData()["rating"]) + val firstChanges = firstSnapshot.getChanges() + assertThat(firstChanges).hasSize(1) + assertThat(firstChanges[0].type).isEqualTo(PipelineResultChange.ChangeType.ADDED) + assertThat(firstChanges[0].result.get("rating")).isInstanceOf(Timestamp::class.java) + assertThat(firstChanges[0].result.get("rating")).isEqualTo(result.get("rating")) + + waitFor(db.enableNetwork()) + + val secondSnapshot = channel.receive() + assertThat(secondSnapshot.metadata.isConsistentBetweenListeners).isTrue() + assertThat(secondSnapshot.results[0].get("rating")).isNotEqualTo(result.getData()["rating"]) + val secondChanges = secondSnapshot.getChanges() + assertThat(secondChanges).hasSize(1) + assertThat(secondChanges[0].type).isEqualTo(PipelineResultChange.ChangeType.MODIFIED) + assertThat(secondChanges[0].result.get("rating")).isInstanceOf(Timestamp::class.java) + assertThat(secondChanges[0].result.get("rating")) + .isEqualTo(secondSnapshot.results[0].get("rating")) + + job.cancel() + } + + @Test + fun testCanEvaluateServerTimestampEstimateProperly() = runBlocking { + waitFor(db.disableNetwork()) + + val now = constant(Timestamp.now()) + collRef.document("book1").update("rating", FieldValue.serverTimestamp()) + + val pipeline = + db + .realtimePipeline() + .collection(collRef.path) + .where(field("rating").timestampAdd(constant("second"), constant(1)).gt(now)) + + val options = + RealtimePipelineOptions() + .withServerTimestampBehavior(DocumentSnapshot.ServerTimestampBehavior.ESTIMATE) + .withMetadataChanges(MetadataChanges.INCLUDE) + + val channel = Channel(Channel.UNLIMITED) + val job = launch { pipeline.snapshots(options).collect { snapshot -> channel.send(snapshot) } } + + val firstSnapshot = channel.receive() + val result = firstSnapshot.results[0] + assertThat(firstSnapshot.metadata.isConsistentBetweenListeners).isFalse() + assertThat(result.get("rating")).isInstanceOf(Timestamp::class.java) + assertThat(result.get("rating")).isEqualTo(result.getData()["rating"]) + + job.cancel() + } + + @Test + fun testCanReadServerTimestampPreviousProperly() = runBlocking { + waitFor(db.disableNetwork()) + + collRef.document("book1").update("rating", FieldValue.serverTimestamp()) + + val pipeline = + db + .realtimePipeline() + .collection(collRef.path) + .where(field("title").eq("The Hitchhiker's Guide to the Galaxy")) + + val options = + RealtimePipelineOptions() + .withServerTimestampBehavior(DocumentSnapshot.ServerTimestampBehavior.PREVIOUS) + + val channel = Channel(Channel.UNLIMITED) + val job = launch { pipeline.snapshots(options).collect { snapshot -> channel.send(snapshot) } } + + val firstSnapshot = channel.receive() + val result = firstSnapshot.results[0] + assertThat(firstSnapshot.metadata.isConsistentBetweenListeners).isFalse() + assertThat(result.get("rating")).isEqualTo(4.2) + assertThat(result.get("rating")).isEqualTo(result.getData()["rating"]) + val firstChanges = firstSnapshot.getChanges() + assertThat(firstChanges).hasSize(1) + assertThat(firstChanges[0].type).isEqualTo(PipelineResultChange.ChangeType.ADDED) + assertThat(firstChanges[0].result.get("rating")).isEqualTo(4.2) + + waitFor(db.enableNetwork()) + + val secondSnapshot = channel.receive() + assertThat(secondSnapshot.metadata.isConsistentBetweenListeners).isTrue() + assertThat(secondSnapshot.results[0].get("rating")).isInstanceOf(Timestamp::class.java) + val secondChanges = secondSnapshot.getChanges() + assertThat(secondChanges).hasSize(1) + assertThat(secondChanges[0].type).isEqualTo(PipelineResultChange.ChangeType.MODIFIED) + assertThat(secondChanges[0].result.get("rating")).isInstanceOf(Timestamp::class.java) + assertThat(secondChanges[0].result.get("rating")) + .isEqualTo(secondSnapshot.results[0].get("rating")) + + job.cancel() + } + + @Test + fun testCanEvaluateServerTimestampPreviousProperly() = runBlocking { + waitFor(db.disableNetwork()) + + collRef.document("book1").update("title", FieldValue.serverTimestamp()) + + val pipeline = + db + .realtimePipeline() + .collection(collRef.path) + .where(field("title").eq("The Hitchhiker's Guide to the Galaxy")) + + val options = + RealtimePipelineOptions() + .withServerTimestampBehavior(DocumentSnapshot.ServerTimestampBehavior.PREVIOUS) + + val channel = Channel(Channel.UNLIMITED) + val job = launch { pipeline.snapshots(options).collect { snapshot -> channel.send(snapshot) } } + + val firstSnapshot = channel.receive() + val result = firstSnapshot.results[0] + assertThat(firstSnapshot.metadata.isConsistentBetweenListeners).isFalse() + assertThat(result.get("title")).isEqualTo("The Hitchhiker's Guide to the Galaxy") + + job.cancel() + } + + @Test + fun testCanReadServerTimestampNoneProperly() = runBlocking { + waitFor(db.disableNetwork()) + + collRef.document("book1").update("rating", FieldValue.serverTimestamp()) + + val pipeline = + db + .realtimePipeline() + .collection(collRef.path) + .where(field("title").eq("The Hitchhiker's Guide to the Galaxy")) + + val channel = Channel(Channel.UNLIMITED) + val job = launch { pipeline.snapshots().collect { snapshot -> channel.send(snapshot) } } + + val firstSnapshot = channel.receive() + val result = firstSnapshot.results[0] + assertThat(firstSnapshot.metadata.isConsistentBetweenListeners).isFalse() + assertThat(result.get("rating")).isNull() + assertThat(result.get("rating")).isEqualTo(result.getData()["rating"]) + val firstChanges = firstSnapshot.getChanges() + assertThat(firstChanges).hasSize(1) + assertThat(firstChanges[0].type).isEqualTo(PipelineResultChange.ChangeType.ADDED) + assertThat(firstChanges[0].result.get("rating")).isNull() + + waitFor(db.enableNetwork()) + + val secondSnapshot = channel.receive() + assertThat(secondSnapshot.metadata.isConsistentBetweenListeners).isTrue() + assertThat(secondSnapshot.results[0].get("rating")).isInstanceOf(Timestamp::class.java) + val secondChanges = secondSnapshot.getChanges() + assertThat(secondChanges).hasSize(1) + assertThat(secondChanges[0].type).isEqualTo(PipelineResultChange.ChangeType.MODIFIED) + assertThat(secondChanges[0].result.get("rating")).isInstanceOf(Timestamp::class.java) + assertThat(secondChanges[0].result.get("rating")) + .isEqualTo(secondSnapshot.results[0].get("rating")) + + job.cancel() + } + + @Test + fun testCanEvaluateServerTimestampNoneProperly() = runBlocking { + waitFor(db.disableNetwork()) + + collRef.document("book1").update("title", FieldValue.serverTimestamp()) + + val pipeline = db.realtimePipeline().collection(collRef.path).where(field("title").isNull()) + + val channel = Channel(Channel.UNLIMITED) + val job = launch { pipeline.snapshots().collect { snapshot -> channel.send(snapshot) } } + + val firstSnapshot = channel.receive() + val result = firstSnapshot.results[0] + assertThat(firstSnapshot.metadata.isConsistentBetweenListeners).isFalse() + assertThat(result.get("title")).isNull() + + job.cancel() + } + + @Test + fun testSamePipelineWithDifferentOptions() = runBlocking { + waitFor(db.disableNetwork()) + + collRef.document("book1").update("title", FieldValue.serverTimestamp()) + + val pipeline = + db.realtimePipeline().collection(collRef.path).where(field("title").isNotNull()).limit(1) + + val channel1 = Channel(Channel.UNLIMITED) + val job1 = launch { + pipeline + .snapshots( + RealtimePipelineOptions() + .withServerTimestampBehavior(DocumentSnapshot.ServerTimestampBehavior.PREVIOUS) + ) + .collect { channel1.send(it) } + } + + val channel2 = Channel(Channel.UNLIMITED) + val job2 = launch { + pipeline + .snapshots( + RealtimePipelineOptions() + .withServerTimestampBehavior(DocumentSnapshot.ServerTimestampBehavior.ESTIMATE) + ) + .collect { channel2.send(it) } + } + + val firstSnapshot1 = channel1.receive() + var result1 = firstSnapshot1.results[0] + assertThat(firstSnapshot1.metadata.isConsistentBetweenListeners).isFalse() + assertThat(result1.get("title")).isEqualTo("The Hitchhiker's Guide to the Galaxy") + + val firstSnapshot2 = channel2.receive() + var result2 = firstSnapshot2.results[0] + assertThat(firstSnapshot2.metadata.isConsistentBetweenListeners).isFalse() + assertThat(result2.get("title")).isInstanceOf(Timestamp::class.java) + + waitFor(db.enableNetwork()) + + val secondSnapshot1 = channel1.receive() + result1 = secondSnapshot1.results[0] + assertThat(secondSnapshot1.metadata.isConsistentBetweenListeners).isTrue() + assertThat(result1.get("title")).isInstanceOf(Timestamp::class.java) + + val secondSnapshot2 = channel2.receive() + result2 = secondSnapshot2.results[0] + assertThat(secondSnapshot2.metadata.isConsistentBetweenListeners).isTrue() + assertThat(result2.get("title")).isInstanceOf(Timestamp::class.java) + + job1.cancel() + job2.cancel() + } + + @Test + fun testLogicalAnd() = runBlocking { + val pipeline = + db + .realtimePipeline() + .collection(collRef.path) + .where( + and( + field("genre").eq("Science Fiction"), + field("rating").gt(4.5), + ) + ) + .sort(ascending("title")) + + val options = RealtimePipelineOptions().withMetadataChanges(MetadataChanges.INCLUDE) + val channel = Channel(Channel.UNLIMITED) + val job = launch { pipeline.snapshots(options).collect { snapshot -> channel.send(snapshot) } } + + val firstSnapshot = channel.receive() + assertThat(firstSnapshot.metadata.isConsistentBetweenListeners).isFalse() + assertThat(firstSnapshot.results).hasSize(1) + assertThat(firstSnapshot.results[0].get("title")).isEqualTo("Dune") + + val secondSnapshot = channel.receive() + assertThat(secondSnapshot.metadata.isConsistentBetweenListeners).isTrue() + assertThat(secondSnapshot.results).hasSize(1) + assertThat(secondSnapshot.getChanges()).isEmpty() + + // Add a book to the result set + collRef.document("book1").update("rating", 4.6).await() + val thirdSnapshot = channel.receive() + assertThat(thirdSnapshot.results).hasSize(2) + assertThat(thirdSnapshot.results[0].get("title")).isEqualTo("Dune") + assertThat(thirdSnapshot.results[1].get("title")) + .isEqualTo("The Hitchhiker's Guide to the Galaxy") + + job.cancel() + } + + @Test + fun testLogicalOr() = runBlocking { + val pipeline = + db + .realtimePipeline() + .collection(collRef.path) + .where( + or( + field("genre").eq("Dystopian"), + field("published").lt(1900), + ) + ) + .sort(ascending("published")) + + val options = RealtimePipelineOptions().withMetadataChanges(MetadataChanges.INCLUDE) + val channel = Channel(Channel.UNLIMITED) + val job = launch { pipeline.snapshots(options).collect { snapshot -> channel.send(snapshot) } } + + val firstSnapshot = channel.receive() + assertThat(firstSnapshot.metadata.isConsistentBetweenListeners).isFalse() + assertThat(firstSnapshot.results).hasSize(4) + assertThat(firstSnapshot.results[0].get("title")).isEqualTo("Pride and Prejudice") // 1813 + assertThat(firstSnapshot.results[1].get("title")).isEqualTo("Crime and Punishment") // 1866 + assertThat(firstSnapshot.results[2].get("title")).isEqualTo("1984") // 1949, Dystopian + assertThat(firstSnapshot.results[3].get("title")) + .isEqualTo("The Handmaid's Tale") // 1985, Dystopian + + val secondSnapshot = channel.receive() + assertThat(secondSnapshot.metadata.isConsistentBetweenListeners).isTrue() + assertThat(secondSnapshot.results).hasSize(4) + assertThat(secondSnapshot.getChanges()).isEmpty() + + // Add a book to the result set + collRef.document("book9").update("genre", "Dystopian").await() + val thirdSnapshot = channel.receive() + assertThat(thirdSnapshot.results).hasSize(5) + assertThat(thirdSnapshot.results[2].get("title")) + .isEqualTo("The Great Gatsby") // 1925, Dystopian + + job.cancel() + } + + @Test + fun testLogicalXor() = runBlocking { + val pipeline = + db + .realtimePipeline() + .collection(collRef.path) + .where( + xor( + field("rating").gt(4.5), + field("genre").eq("Science Fiction"), + ) + ) + .sort(ascending("rating")) + + val options = RealtimePipelineOptions().withMetadataChanges(MetadataChanges.INCLUDE) + val channel = Channel(Channel.UNLIMITED) + val job = launch { pipeline.snapshots(options).collect { snapshot -> channel.send(snapshot) } } + + val firstSnapshot = channel.receive() + assertThat(firstSnapshot.metadata.isConsistentBetweenListeners).isFalse() + assertThat(firstSnapshot.results).hasSize(2) + assertThat(firstSnapshot.results[0].get("title")) + .isEqualTo("The Hitchhiker's Guide to the Galaxy") // rating 4.2, SF -> false XOR true = true + assertThat(firstSnapshot.results[1].get("title")) + .isEqualTo("The Lord of the Rings") // rating 4.7, Fantasy -> true XOR false = true + + val secondSnapshot = channel.receive() + assertThat(secondSnapshot.metadata.isConsistentBetweenListeners).isTrue() + assertThat(secondSnapshot.results).hasSize(2) + assertThat(secondSnapshot.getChanges()).isEmpty() + + // Modify a book to be excluded by making both conditions true + collRef + .document("book1") + .update("rating", 4.7) + .await() // Hitchhiker's Guide: rating 4.7, SF -> true XOR true = false + val thirdSnapshot = channel.receive() + assertThat(thirdSnapshot.results).hasSize(1) + assertThat(thirdSnapshot.results[0].get("title")).isEqualTo("The Lord of the Rings") + + job.cancel() + } + + @Test + fun testNotFunction() = runBlocking { + val pipeline = + db + .realtimePipeline() + .collection(collRef.path) + .where(not(field("genre").eq("Science Fiction"))) + .sort(ascending("published")) + + val options = RealtimePipelineOptions().withMetadataChanges(MetadataChanges.INCLUDE) + val channel = Channel(Channel.UNLIMITED) + val job = launch { pipeline.snapshots(options).collect { snapshot -> channel.send(snapshot) } } + + val firstSnapshot = channel.receive() + assertThat(firstSnapshot.metadata.isConsistentBetweenListeners).isFalse() + assertThat(firstSnapshot.results).hasSize(8) + assertThat(firstSnapshot.results.map { it.get("genre") as String }) + .doesNotContain("Science Fiction") + + val secondSnapshot = channel.receive() + assertThat(secondSnapshot.metadata.isConsistentBetweenListeners).isTrue() + assertThat(secondSnapshot.results).hasSize(8) + assertThat(secondSnapshot.getChanges()).isEmpty() + + // Modify a book to be excluded + collRef.document("book2").update("genre", "Science Fiction").await() + val thirdSnapshot = channel.receive() + assertThat(thirdSnapshot.results).hasSize(7) + assertThat(thirdSnapshot.results.map { it.get("title") as String }) + .doesNotContain("Pride and Prejudice") + + job.cancel() + } + + @Test + fun testEqAny() = runBlocking { + val pipeline = + db + .realtimePipeline() + .collection(collRef.path) + .where(eqAny("genre", listOf("Dystopian", "Fantasy"))) + .sort(ascending("published")) + + val options = RealtimePipelineOptions().withMetadataChanges(MetadataChanges.INCLUDE) + val channel = Channel(Channel.UNLIMITED) + val job = launch { pipeline.snapshots(options).collect { snapshot -> channel.send(snapshot) } } + + val firstSnapshot = channel.receive() + assertThat(firstSnapshot.metadata.isConsistentBetweenListeners).isFalse() + assertThat(firstSnapshot.results).hasSize(3) + assertThat(firstSnapshot.results[0].get("title")).isEqualTo("1984") + assertThat(firstSnapshot.results[1].get("title")).isEqualTo("The Lord of the Rings") + assertThat(firstSnapshot.results[2].get("title")).isEqualTo("The Handmaid's Tale") + + val secondSnapshot = channel.receive() + assertThat(secondSnapshot.metadata.isConsistentBetweenListeners).isTrue() + assertThat(secondSnapshot.results).hasSize(3) + assertThat(secondSnapshot.getChanges()).isEmpty() + + // Add a book to the result set + collRef.document("book9").update("genre", "Dystopian").await() + val thirdSnapshot = channel.receive() + assertThat(thirdSnapshot.results).hasSize(4) + assertThat(thirdSnapshot.results[0].get("title")).isEqualTo("The Great Gatsby") + assertThat(thirdSnapshot.getChanges()[0].type).isEqualTo(PipelineResultChange.ChangeType.ADDED) + assertThat(thirdSnapshot.getChanges()[0].result.get("title")).isEqualTo("The Great Gatsby") + assertThat(thirdSnapshot.getChanges()[0].newIndex).isEqualTo(0) + + job.cancel() + } + + @Test + fun testNotEqAny() = runBlocking { + val pipeline = + db + .realtimePipeline() + .collection(collRef.path) + .where( + notEqAny( + "genre", + listOf( + "Dystopian", + "Fantasy", + "Science Fiction", + "Romance", + "Magical Realism", + "Psychological Thriller", + "Southern Gothic" + ) + ) + ) + .sort(ascending("published")) + + val options = RealtimePipelineOptions().withMetadataChanges(MetadataChanges.INCLUDE) + val channel = Channel(Channel.UNLIMITED) + val job = launch { pipeline.snapshots(options).collect { snapshot -> channel.send(snapshot) } } + + val firstSnapshot = channel.receive() + assertThat(firstSnapshot.metadata.isConsistentBetweenListeners).isFalse() + assertThat(firstSnapshot.results).hasSize(1) + assertThat(firstSnapshot.results[0].get("title")).isEqualTo("The Great Gatsby") + + val secondSnapshot = channel.receive() + assertThat(secondSnapshot.metadata.isConsistentBetweenListeners).isTrue() + assertThat(secondSnapshot.results).hasSize(1) + assertThat(secondSnapshot.getChanges()).isEmpty() + + // Remove a book from the result set + collRef.document("book9").update("genre", "Dystopian").await() + val thirdSnapshot = channel.receive() + assertThat(thirdSnapshot.results).hasSize(0) + + job.cancel() + } + + @Test + fun testIsAbsent() = runBlocking { + collRef.document("book1").update("rating", FieldValue.delete()).await() + val pipeline = + db + .realtimePipeline() + .collection(collRef.path) + .where(isAbsent("rating")) + .sort(ascending("published")) + + val options = RealtimePipelineOptions().withMetadataChanges(MetadataChanges.INCLUDE) + val channel = Channel(Channel.UNLIMITED) + val job = launch { pipeline.snapshots(options).collect { snapshot -> channel.send(snapshot) } } + + val firstSnapshot = channel.receive() + assertThat(firstSnapshot.metadata.isConsistentBetweenListeners).isFalse() + assertThat(firstSnapshot.results).hasSize(1) + assertThat(firstSnapshot.results[0].get("title")) + .isEqualTo("The Hitchhiker's Guide to the Galaxy") + + val secondSnapshot = channel.receive() + assertThat(secondSnapshot.metadata.isConsistentBetweenListeners).isTrue() + assertThat(secondSnapshot.results).hasSize(1) + assertThat(secondSnapshot.getChanges()).isEmpty() + + job.cancel() + } + + @Test + fun testExists() = runBlocking { + collRef.document("book1").update("rating", FieldValue.delete()).await() + val pipeline = + db + .realtimePipeline() + .collection(collRef.path) + .where(not(exists("rating"))) + .sort(ascending("published")) + + val options = RealtimePipelineOptions().withMetadataChanges(MetadataChanges.INCLUDE) + val channel = Channel(Channel.UNLIMITED) + val job = launch { pipeline.snapshots(options).collect { snapshot -> channel.send(snapshot) } } + + val firstSnapshot = channel.receive() + assertThat(firstSnapshot.metadata.isConsistentBetweenListeners).isFalse() + assertThat(firstSnapshot.results).hasSize(1) + assertThat(firstSnapshot.results[0].get("title")) + .isEqualTo("The Hitchhiker's Guide to the Galaxy") + + val secondSnapshot = channel.receive() + assertThat(secondSnapshot.metadata.isConsistentBetweenListeners).isTrue() + assertThat(secondSnapshot.results).hasSize(1) + assertThat(secondSnapshot.getChanges()).isEmpty() + + job.cancel() + } + + @Test + fun testIsNanAndIsNotNan() = runBlocking { + collRef.document("book1").update("rating", Double.NaN).await() + + // Test isNan + val pipelineIsNan = db.realtimePipeline().collection(collRef.path).where(isNan("rating")) + + val channelIsNan = Channel(Channel.UNLIMITED) + val jobIsNan = launch { + pipelineIsNan.snapshots().collect { snapshot -> channelIsNan.send(snapshot) } + } + + val snapshotIsNan = channelIsNan.receive() + assertThat(snapshotIsNan.results).hasSize(1) + assertThat(snapshotIsNan.results[0].get("title")) + .isEqualTo("The Hitchhiker's Guide to the Galaxy") + jobIsNan.cancel() + + // Test isNotNan + val pipelineIsNotNan = db.realtimePipeline().collection(collRef.path).where(isNotNan("rating")) + + val channelIsNotNan = Channel(Channel.UNLIMITED) + val jobIsNotNan = launch { + pipelineIsNotNan.snapshots().collect { snapshot -> channelIsNotNan.send(snapshot) } + } + + val snapshotIsNotNan = channelIsNotNan.receive() + assertThat(snapshotIsNotNan.results).hasSize(9) + jobIsNotNan.cancel() + } + + @Test + fun testIsNullAndIsNotNull() = runBlocking { + collRef.document("book1").update("rating", null).await() + + // Test isNull + val pipelineIsNull = db.realtimePipeline().collection(collRef.path).where(isNull("rating")) + + val channelIsNull = Channel(Channel.UNLIMITED) + val jobIsNull = launch { + pipelineIsNull.snapshots().collect { snapshot -> channelIsNull.send(snapshot) } + } + + val snapshotIsNull = channelIsNull.receive() + assertThat(snapshotIsNull.results).hasSize(1) + assertThat(snapshotIsNull.results[0].get("title")) + .isEqualTo("The Hitchhiker's Guide to the Galaxy") + jobIsNull.cancel() + + // Test isNotNull + val pipelineIsNotNull = + db.realtimePipeline().collection(collRef.path).where(isNotNull("rating")) + + val channelIsNotNull = Channel(Channel.UNLIMITED) + val jobIsNotNull = launch { + pipelineIsNotNull.snapshots().collect { snapshot -> channelIsNotNull.send(snapshot) } + } + + val snapshotIsNotNull = channelIsNotNull.receive() + assertThat(snapshotIsNotNull.results).hasSize(9) + jobIsNotNull.cancel() + } + + @Test + fun testStrConcat() = runBlocking { + val pipeline = + db + .realtimePipeline() + .collection(collRef.path) + .where(field("author").eq(strConcat(constant("Douglas"), constant(" Adams")))) + + val options = RealtimePipelineOptions().withMetadataChanges(MetadataChanges.INCLUDE) + val channel = Channel(Channel.UNLIMITED) + val job = launch { pipeline.snapshots(options).collect { snapshot -> channel.send(snapshot) } } + + val firstSnapshot = channel.receive() + assertThat(firstSnapshot.metadata.isConsistentBetweenListeners).isFalse() + assertThat(firstSnapshot.results).hasSize(1) + assertThat(firstSnapshot.results[0].get("title")) + .isEqualTo("The Hitchhiker's Guide to the Galaxy") + + val secondSnapshot = channel.receive() + assertThat(secondSnapshot.metadata.isConsistentBetweenListeners).isTrue() + assertThat(secondSnapshot.results).hasSize(1) + assertThat(secondSnapshot.getChanges()).isEmpty() + + job.cancel() + } + + @Test + fun testToLower() = runBlocking { + val pipeline = + db + .realtimePipeline() + .collection(collRef.path) + .where(field("author").toLower().eq(toLower(constant("DOUGLAS ADAMS")))) + + val options = RealtimePipelineOptions().withMetadataChanges(MetadataChanges.INCLUDE) + val channel = Channel(Channel.UNLIMITED) + val job = launch { pipeline.snapshots(options).collect { snapshot -> channel.send(snapshot) } } + + val firstSnapshot = channel.receive() + assertThat(firstSnapshot.metadata.isConsistentBetweenListeners).isFalse() + assertThat(firstSnapshot.results).hasSize(1) + assertThat(firstSnapshot.results[0].get("title")) + .isEqualTo("The Hitchhiker's Guide to the Galaxy") + + val secondSnapshot = channel.receive() + assertThat(secondSnapshot.metadata.isConsistentBetweenListeners).isTrue() + assertThat(secondSnapshot.results).hasSize(1) + assertThat(secondSnapshot.getChanges()).isEmpty() + + job.cancel() + } + + @Test + fun testToUpper() = runBlocking { + val pipeline = + db + .realtimePipeline() + .collection(collRef.path) + .where(field("author").toUpper().eq(toUpper(constant("dOUglAs adaMs")))) + + val options = RealtimePipelineOptions().withMetadataChanges(MetadataChanges.INCLUDE) + val channel = Channel(Channel.UNLIMITED) + val job = launch { pipeline.snapshots(options).collect { snapshot -> channel.send(snapshot) } } + + val firstSnapshot = channel.receive() + assertThat(firstSnapshot.metadata.isConsistentBetweenListeners).isFalse() + assertThat(firstSnapshot.results).hasSize(1) + assertThat(firstSnapshot.results[0].get("title")) + .isEqualTo("The Hitchhiker's Guide to the Galaxy") + + val secondSnapshot = channel.receive() + assertThat(secondSnapshot.metadata.isConsistentBetweenListeners).isTrue() + assertThat(secondSnapshot.results).hasSize(1) + assertThat(secondSnapshot.getChanges()).isEmpty() + + job.cancel() + } + + @Test + fun testTrim() = runBlocking { + val pipeline = + db + .realtimePipeline() + .collection(collRef.path) + .where(field("author").eq(trim(constant(" Douglas Adams ")))) + + val options = RealtimePipelineOptions().withMetadataChanges(MetadataChanges.INCLUDE) + val channel = Channel(Channel.UNLIMITED) + val job = launch { pipeline.snapshots(options).collect { snapshot -> channel.send(snapshot) } } + + val firstSnapshot = channel.receive() + assertThat(firstSnapshot.metadata.isConsistentBetweenListeners).isFalse() + assertThat(firstSnapshot.results).hasSize(1) + assertThat(firstSnapshot.results[0].get("title")) + .isEqualTo("The Hitchhiker's Guide to the Galaxy") + + val secondSnapshot = channel.receive() + assertThat(secondSnapshot.metadata.isConsistentBetweenListeners).isTrue() + assertThat(secondSnapshot.results).hasSize(1) + assertThat(secondSnapshot.getChanges()).isEmpty() + + job.cancel() + } + + @Test + fun testCharLength() = runBlocking { + val pipeline = db.realtimePipeline().collection(collRef.path).where(charLength("author").gt(20)) + + val options = RealtimePipelineOptions().withMetadataChanges(MetadataChanges.INCLUDE) + val channel = Channel(Channel.UNLIMITED) + val job = launch { pipeline.snapshots(options).collect { snapshot -> channel.send(snapshot) } } + + val firstSnapshot = channel.receive() + assertThat(firstSnapshot.metadata.isConsistentBetweenListeners).isFalse() + assertThat(firstSnapshot.results).hasSize(1) + assertThat(firstSnapshot.results[0].get("title")).isEqualTo("One Hundred Years of Solitude") + + val secondSnapshot = channel.receive() + assertThat(secondSnapshot.metadata.isConsistentBetweenListeners).isTrue() + assertThat(secondSnapshot.results).hasSize(1) + assertThat(secondSnapshot.getChanges()).isEmpty() + + job.cancel() + } + + @Test + fun testByteLength() = runBlocking { + val pipeline = db.realtimePipeline().collection(collRef.path).where(byteLength("author").gt(20)) + + val options = RealtimePipelineOptions().withMetadataChanges(MetadataChanges.INCLUDE) + val channel = Channel(Channel.UNLIMITED) + val job = launch { pipeline.snapshots(options).collect { snapshot -> channel.send(snapshot) } } + + val firstSnapshot = channel.receive() + assertThat(firstSnapshot.metadata.isConsistentBetweenListeners).isFalse() + assertThat(firstSnapshot.results).hasSize(1) + assertThat(firstSnapshot.results[0].get("title")).isEqualTo("One Hundred Years of Solitude") + + val secondSnapshot = channel.receive() + assertThat(secondSnapshot.metadata.isConsistentBetweenListeners).isTrue() + assertThat(secondSnapshot.results).hasSize(1) + assertThat(secondSnapshot.getChanges()).isEmpty() + + job.cancel() + } + + @Test + fun testReverse() = runBlocking { + val pipeline = + db + .realtimePipeline() + .collection(collRef.path) + .where(field("author").eq(reverse(constant("smadA salguoD")))) + + val options = RealtimePipelineOptions().withMetadataChanges(MetadataChanges.INCLUDE) + val channel = Channel(Channel.UNLIMITED) + val job = launch { pipeline.snapshots(options).collect { snapshot -> channel.send(snapshot) } } + + val firstSnapshot = channel.receive() + assertThat(firstSnapshot.metadata.isConsistentBetweenListeners).isFalse() + assertThat(firstSnapshot.results).hasSize(1) + assertThat(firstSnapshot.results[0].get("title")) + .isEqualTo("The Hitchhiker's Guide to the Galaxy") + + val secondSnapshot = channel.receive() + assertThat(secondSnapshot.metadata.isConsistentBetweenListeners).isTrue() + assertThat(secondSnapshot.results).hasSize(1) + assertThat(secondSnapshot.getChanges()).isEmpty() + + job.cancel() + } + + @Test + fun testStrContains() = runBlocking { + val pipeline = + db.realtimePipeline().collection(collRef.path).where(strContains("author", "Adams")) + + val options = RealtimePipelineOptions().withMetadataChanges(MetadataChanges.INCLUDE) + val channel = Channel(Channel.UNLIMITED) + val job = launch { pipeline.snapshots(options).collect { snapshot -> channel.send(snapshot) } } + + val firstSnapshot = channel.receive() + assertThat(firstSnapshot.metadata.isConsistentBetweenListeners).isFalse() + assertThat(firstSnapshot.results).hasSize(1) + assertThat(firstSnapshot.results[0].get("title")) + .isEqualTo("The Hitchhiker's Guide to the Galaxy") + + val secondSnapshot = channel.receive() + assertThat(secondSnapshot.metadata.isConsistentBetweenListeners).isTrue() + assertThat(secondSnapshot.results).hasSize(1) + assertThat(secondSnapshot.getChanges()).isEmpty() + + job.cancel() + } + + @Test + fun testStartsWith() = runBlocking { + val pipeline = + db.realtimePipeline().collection(collRef.path).where(startsWith("author", "Douglas")) + + val options = RealtimePipelineOptions().withMetadataChanges(MetadataChanges.INCLUDE) + val channel = Channel(Channel.UNLIMITED) + val job = launch { pipeline.snapshots(options).collect { snapshot -> channel.send(snapshot) } } + + val firstSnapshot = channel.receive() + assertThat(firstSnapshot.metadata.isConsistentBetweenListeners).isFalse() + assertThat(firstSnapshot.results).hasSize(1) + assertThat(firstSnapshot.results[0].get("title")) + .isEqualTo("The Hitchhiker's Guide to the Galaxy") + + val secondSnapshot = channel.receive() + assertThat(secondSnapshot.metadata.isConsistentBetweenListeners).isTrue() + assertThat(secondSnapshot.results).hasSize(1) + assertThat(secondSnapshot.getChanges()).isEmpty() + + job.cancel() + } + + @Test + fun testEndsWith() = runBlocking { + val pipeline = db.realtimePipeline().collection(collRef.path).where(endsWith("author", "Adams")) + + val options = RealtimePipelineOptions().withMetadataChanges(MetadataChanges.INCLUDE) + val channel = Channel(Channel.UNLIMITED) + val job = launch { pipeline.snapshots(options).collect { snapshot -> channel.send(snapshot) } } + + val firstSnapshot = channel.receive() + assertThat(firstSnapshot.metadata.isConsistentBetweenListeners).isFalse() + assertThat(firstSnapshot.results).hasSize(1) + assertThat(firstSnapshot.results[0].get("title")) + .isEqualTo("The Hitchhiker's Guide to the Galaxy") + + val secondSnapshot = channel.receive() + assertThat(secondSnapshot.metadata.isConsistentBetweenListeners).isTrue() + assertThat(secondSnapshot.results).hasSize(1) + assertThat(secondSnapshot.getChanges()).isEmpty() + + job.cancel() + } + + @Test + @Ignore("Not supported yet") + fun testLike() = runBlocking { + val pipeline = db.realtimePipeline().collection(collRef.path).where(like("author", "Douglas%")) + + val options = RealtimePipelineOptions().withMetadataChanges(MetadataChanges.INCLUDE) + val channel = Channel(Channel.UNLIMITED) + val job = launch { pipeline.snapshots(options).collect { snapshot -> channel.send(snapshot) } } + + val firstSnapshot = channel.receive() + assertThat(firstSnapshot.metadata.isConsistentBetweenListeners).isFalse() + assertThat(firstSnapshot.results).hasSize(1) + assertThat(firstSnapshot.results[0].get("title")) + .isEqualTo("The Hitchhiker's Guide to the Galaxy") + + val secondSnapshot = channel.receive() + assertThat(secondSnapshot.metadata.isConsistentBetweenListeners).isTrue() + assertThat(secondSnapshot.results).hasSize(1) + assertThat(secondSnapshot.getChanges()).isEmpty() + + job.cancel() + } + + @Test + @Ignore("Not supported yet") + fun testRegexContains() = runBlocking { + val pipeline = + db.realtimePipeline().collection(collRef.path).where(regexContains("author", "Douglas.*")) + + val options = RealtimePipelineOptions().withMetadataChanges(MetadataChanges.INCLUDE) + val channel = Channel(Channel.UNLIMITED) + val job = launch { pipeline.snapshots(options).collect { snapshot -> channel.send(snapshot) } } + + val firstSnapshot = channel.receive() + assertThat(firstSnapshot.metadata.isConsistentBetweenListeners).isFalse() + assertThat(firstSnapshot.results).hasSize(1) + assertThat(firstSnapshot.results[0].get("title")) + .isEqualTo("The Hitchhiker's Guide to the Galaxy") + + val secondSnapshot = channel.receive() + assertThat(secondSnapshot.metadata.isConsistentBetweenListeners).isTrue() + assertThat(secondSnapshot.results).hasSize(1) + assertThat(secondSnapshot.getChanges()).isEmpty() + + job.cancel() + } + + @Test + @Ignore("Not supported yet") + fun testRegexMatch() = runBlocking { + val pipeline = + db.realtimePipeline().collection(collRef.path).where(regexMatch("author", "Douglas Adams")) + + val options = RealtimePipelineOptions().withMetadataChanges(MetadataChanges.INCLUDE) + val channel = Channel(Channel.UNLIMITED) + val job = launch { pipeline.snapshots(options).collect { snapshot -> channel.send(snapshot) } } + + val firstSnapshot = channel.receive() + assertThat(firstSnapshot.metadata.isConsistentBetweenListeners).isFalse() + assertThat(firstSnapshot.results).hasSize(1) + assertThat(firstSnapshot.results[0].get("title")) + .isEqualTo("The Hitchhiker's Guide to the Galaxy") + + val secondSnapshot = channel.receive() + assertThat(secondSnapshot.metadata.isConsistentBetweenListeners).isTrue() + assertThat(secondSnapshot.results).hasSize(1) + assertThat(secondSnapshot.getChanges()).isEmpty() + + job.cancel() + } + + @Test + fun testAdd() = runBlocking { + val pipeline = db.realtimePipeline().collection(collRef.path).where(add("rating", 0.8).eq(5.0)) + + val options = RealtimePipelineOptions().withMetadataChanges(MetadataChanges.INCLUDE) + val channel = Channel(Channel.UNLIMITED) + val job = launch { pipeline.snapshots(options).collect { snapshot -> channel.send(snapshot) } } + + val firstSnapshot = channel.receive() + assertThat(firstSnapshot.metadata.isConsistentBetweenListeners).isFalse() + assertThat(firstSnapshot.results).hasSize(3) + assertThat(firstSnapshot.results[0].get("title")) + .isEqualTo("The Hitchhiker's Guide to the Galaxy") + assertThat(firstSnapshot.results[1].get("title")).isEqualTo("To Kill a Mockingbird") + assertThat(firstSnapshot.results[2].get("title")).isEqualTo("1984") + + val secondSnapshot = channel.receive() + assertThat(secondSnapshot.metadata.isConsistentBetweenListeners).isTrue() + assertThat(secondSnapshot.results).hasSize(3) + assertThat(secondSnapshot.getChanges()).isEmpty() + + job.cancel() + } + + @Test + fun testSubtract() = runBlocking { + val pipeline = + db.realtimePipeline().collection(collRef.path).where(subtract("rating", 0.2).eq(4.0)) + + val options = RealtimePipelineOptions().withMetadataChanges(MetadataChanges.INCLUDE) + val channel = Channel(Channel.UNLIMITED) + val job = launch { pipeline.snapshots(options).collect { snapshot -> channel.send(snapshot) } } + + val firstSnapshot = channel.receive() + assertThat(firstSnapshot.metadata.isConsistentBetweenListeners).isFalse() + assertThat(firstSnapshot.results).hasSize(3) + assertThat(firstSnapshot.results[0].get("title")) + .isEqualTo("The Hitchhiker's Guide to the Galaxy") + assertThat(firstSnapshot.results[1].get("title")).isEqualTo("To Kill a Mockingbird") + assertThat(firstSnapshot.results[2].get("title")).isEqualTo("1984") + + val secondSnapshot = channel.receive() + assertThat(secondSnapshot.metadata.isConsistentBetweenListeners).isTrue() + assertThat(secondSnapshot.results).hasSize(3) + assertThat(secondSnapshot.getChanges()).isEmpty() + + job.cancel() + } + + @Test + fun testMultiply() = runBlocking { + val pipeline = + db.realtimePipeline().collection(collRef.path).where(multiply("rating", 2.0).eq(8.4)) + + val options = RealtimePipelineOptions().withMetadataChanges(MetadataChanges.INCLUDE) + val channel = Channel(Channel.UNLIMITED) + val job = launch { pipeline.snapshots(options).collect { snapshot -> channel.send(snapshot) } } + + val firstSnapshot = channel.receive() + assertThat(firstSnapshot.metadata.isConsistentBetweenListeners).isFalse() + assertThat(firstSnapshot.results).hasSize(3) + assertThat(firstSnapshot.results[0].get("title")) + .isEqualTo("The Hitchhiker's Guide to the Galaxy") + assertThat(firstSnapshot.results[1].get("title")).isEqualTo("To Kill a Mockingbird") + assertThat(firstSnapshot.results[2].get("title")).isEqualTo("1984") + + val secondSnapshot = channel.receive() + assertThat(secondSnapshot.metadata.isConsistentBetweenListeners).isTrue() + assertThat(secondSnapshot.results).hasSize(3) + assertThat(secondSnapshot.getChanges()).isEmpty() + + job.cancel() + } + + @Test + fun testDivide() = runBlocking { + val pipeline = + db.realtimePipeline().collection(collRef.path).where(divide("rating", 2.0).eq(2.1)) + + val options = RealtimePipelineOptions().withMetadataChanges(MetadataChanges.INCLUDE) + val channel = Channel(Channel.UNLIMITED) + val job = launch { pipeline.snapshots(options).collect { snapshot -> channel.send(snapshot) } } + + val firstSnapshot = channel.receive() + assertThat(firstSnapshot.metadata.isConsistentBetweenListeners).isFalse() + assertThat(firstSnapshot.results).hasSize(3) + assertThat(firstSnapshot.results[0].get("title")) + .isEqualTo("The Hitchhiker's Guide to the Galaxy") + assertThat(firstSnapshot.results[1].get("title")).isEqualTo("To Kill a Mockingbird") + assertThat(firstSnapshot.results[2].get("title")).isEqualTo("1984") + + val secondSnapshot = channel.receive() + assertThat(secondSnapshot.metadata.isConsistentBetweenListeners).isTrue() + assertThat(secondSnapshot.results).hasSize(3) + assertThat(secondSnapshot.getChanges()).isEmpty() + + job.cancel() + } + + @Test + fun testMod() = runBlocking { + val pipeline = + db.realtimePipeline().collection(collRef.path).where(mod("published", 100).eq(79)) + + val options = RealtimePipelineOptions().withMetadataChanges(MetadataChanges.INCLUDE) + val channel = Channel(Channel.UNLIMITED) + val job = launch { pipeline.snapshots(options).collect { snapshot -> channel.send(snapshot) } } + + val firstSnapshot = channel.receive() + assertThat(firstSnapshot.metadata.isConsistentBetweenListeners).isFalse() + assertThat(firstSnapshot.results).hasSize(1) + assertThat(firstSnapshot.results[0].get("title")) + .isEqualTo("The Hitchhiker's Guide to the Galaxy") + + val secondSnapshot = channel.receive() + assertThat(secondSnapshot.metadata.isConsistentBetweenListeners).isTrue() + assertThat(secondSnapshot.results).hasSize(1) + assertThat(secondSnapshot.getChanges()).isEmpty() + + job.cancel() + } + + @Test + fun testPow() = runBlocking { + val pipeline = db.realtimePipeline().collection(collRef.path).where(pow("rating", 2.0).gt(20.0)) + + val options = RealtimePipelineOptions().withMetadataChanges(MetadataChanges.INCLUDE) + val channel = Channel(Channel.UNLIMITED) + val job = launch { pipeline.snapshots(options).collect { snapshot -> channel.send(snapshot) } } + + val firstSnapshot = channel.receive() + assertThat(firstSnapshot.metadata.isConsistentBetweenListeners).isFalse() + assertThat(firstSnapshot.results).hasSize(3) + + val secondSnapshot = channel.receive() + assertThat(secondSnapshot.metadata.isConsistentBetweenListeners).isTrue() + assertThat(secondSnapshot.results).hasSize(3) + assertThat(secondSnapshot.getChanges()).isEmpty() + + job.cancel() + } + + @Test + fun testAbs() = runBlocking { + collRef.document("book1").update("rating", -4.2).await() + val pipeline = db.realtimePipeline().collection(collRef.path).where(abs("rating").eq(4.2)) + + val options = RealtimePipelineOptions().withMetadataChanges(MetadataChanges.INCLUDE) + val channel = Channel(Channel.UNLIMITED) + val job = launch { pipeline.snapshots(options).collect { snapshot -> channel.send(snapshot) } } + + val firstSnapshot = channel.receive() + assertThat(firstSnapshot.metadata.isConsistentBetweenListeners).isFalse() + assertThat(firstSnapshot.results).hasSize(3) + assertThat(firstSnapshot.results.map { it.get("title") }) + .containsExactly("The Hitchhiker's Guide to the Galaxy", "To Kill a Mockingbird", "1984") + + val secondSnapshot = channel.receive() + assertThat(secondSnapshot.metadata.isConsistentBetweenListeners).isTrue() + assertThat(secondSnapshot.results).hasSize(3) + assertThat(secondSnapshot.getChanges()).isEmpty() + + job.cancel() + } + + @Test + fun testExp() = runBlocking { + collRef.document("book1").update("log_rating", 1.4350845335).await() // ln(4.2) + val pipeline = + db + .realtimePipeline() + .collection(collRef.path) + .where(and(exp("log_rating").gt(4.19), exp("log_rating").lt(4.21))) + + val options = RealtimePipelineOptions().withMetadataChanges(MetadataChanges.INCLUDE) + val channel = Channel(Channel.UNLIMITED) + val job = launch { pipeline.snapshots(options).collect { snapshot -> channel.send(snapshot) } } + + val firstSnapshot = channel.receive() + assertThat(firstSnapshot.results).hasSize(1) + assertThat(firstSnapshot.metadata.isConsistentBetweenListeners).isFalse() + assertThat(firstSnapshot.results[0].get("title")) + .isEqualTo("The Hitchhiker's Guide to the Galaxy") + + val secondSnapshot = channel.receive() + assertThat(secondSnapshot.metadata.isConsistentBetweenListeners).isTrue() + assertThat(secondSnapshot.results).hasSize(1) + assertThat(secondSnapshot.getChanges()).isEmpty() + + job.cancel() + } + + @Test + fun testLn() = runBlocking { + val pipeline = + db + .realtimePipeline() + .collection(collRef.path) + .where(and(ln("rating").gt(1.43), ln("rating").lt(1.44))) + + val options = RealtimePipelineOptions().withMetadataChanges(MetadataChanges.INCLUDE) + val channel = Channel(Channel.UNLIMITED) + val job = launch { pipeline.snapshots(options).collect { snapshot -> channel.send(snapshot) } } + + val firstSnapshot = channel.receive() + assertThat(firstSnapshot.metadata.isConsistentBetweenListeners).isFalse() + assertThat(firstSnapshot.results).hasSize(3) + assertThat(firstSnapshot.results.map { it.get("title") }) + .containsExactly("The Hitchhiker's Guide to the Galaxy", "To Kill a Mockingbird", "1984") + + val secondSnapshot = channel.receive() + assertThat(secondSnapshot.metadata.isConsistentBetweenListeners).isTrue() + assertThat(secondSnapshot.results).hasSize(3) + assertThat(secondSnapshot.getChanges()).isEmpty() + + job.cancel() + } + + @Test + fun testLog10() = runBlocking { + val pipeline = + db + .realtimePipeline() + .collection(collRef.path) + .where(log10("published").eq(kotlin.math.log10(1979.0))) + + val options = RealtimePipelineOptions().withMetadataChanges(MetadataChanges.INCLUDE) + val channel = Channel(Channel.UNLIMITED) + val job = launch { pipeline.snapshots(options).collect { snapshot -> channel.send(snapshot) } } + + val firstSnapshot = channel.receive() + assertThat(firstSnapshot.metadata.isConsistentBetweenListeners).isFalse() + assertThat(firstSnapshot.results).hasSize(1) + assertThat(firstSnapshot.results[0].get("title")) + .isEqualTo("The Hitchhiker's Guide to the Galaxy") + + val secondSnapshot = channel.receive() + assertThat(secondSnapshot.metadata.isConsistentBetweenListeners).isTrue() + assertThat(secondSnapshot.results).hasSize(1) + assertThat(secondSnapshot.getChanges()).isEmpty() + + job.cancel() + } + + @Test + fun testLog() = runBlocking { + val pipeline = + db + .realtimePipeline() + .collection(collRef.path) + .where(log("published", constant(4.2)).eq(kotlin.math.log(1954.0, 4.2))) + + val options = RealtimePipelineOptions().withMetadataChanges(MetadataChanges.INCLUDE) + val channel = Channel(Channel.UNLIMITED) + val job = launch { pipeline.snapshots(options).collect { snapshot -> channel.send(snapshot) } } + + val firstSnapshot = channel.receive() + assertThat(firstSnapshot.metadata.isConsistentBetweenListeners).isFalse() + assertThat(firstSnapshot.results).hasSize(1) + assertThat(firstSnapshot.results[0].get("title")).isEqualTo("The Lord of the Rings") + + val secondSnapshot = channel.receive() + assertThat(secondSnapshot.metadata.isConsistentBetweenListeners).isTrue() + assertThat(secondSnapshot.results).hasSize(1) + assertThat(secondSnapshot.getChanges()).isEmpty() + + job.cancel() + } + + @Test + fun testSqrt() = runBlocking { + val pipeline = + // published since 1952 + db.realtimePipeline().collection(collRef.path).where(sqrt("published").gt(44.18)) + + val options = RealtimePipelineOptions().withMetadataChanges(MetadataChanges.INCLUDE) + val channel = Channel(Channel.UNLIMITED) + val job = launch { pipeline.snapshots(options).collect { snapshot -> channel.send(snapshot) } } + + val firstSnapshot = channel.receive() + assertThat(firstSnapshot.metadata.isConsistentBetweenListeners).isFalse() + assertThat(firstSnapshot.results).hasSize(6) + + val secondSnapshot = channel.receive() + assertThat(secondSnapshot.metadata.isConsistentBetweenListeners).isTrue() + assertThat(secondSnapshot.results).hasSize(6) + assertThat(secondSnapshot.getChanges()).isEmpty() + + job.cancel() + } + + @Test + fun testRound() = runBlocking { + val pipeline = db.realtimePipeline().collection(collRef.path).where(round("rating").eq(5.0)) + + val options = RealtimePipelineOptions().withMetadataChanges(MetadataChanges.INCLUDE) + val channel = Channel(Channel.UNLIMITED) + val job = launch { pipeline.snapshots(options).collect { snapshot -> channel.send(snapshot) } } + + val firstSnapshot = channel.receive() + assertThat(firstSnapshot.metadata.isConsistentBetweenListeners).isFalse() + assertThat(firstSnapshot.results).hasSize(3) + + val secondSnapshot = channel.receive() + assertThat(secondSnapshot.metadata.isConsistentBetweenListeners).isTrue() + assertThat(secondSnapshot.results).hasSize(3) + assertThat(secondSnapshot.getChanges()).isEmpty() + + job.cancel() + } + + @Test + fun testCeil() = runBlocking { + val pipeline = db.realtimePipeline().collection(collRef.path).where(ceil("rating").eq(5.0)) + + val options = RealtimePipelineOptions().withMetadataChanges(MetadataChanges.INCLUDE) + val channel = Channel(Channel.UNLIMITED) + val job = launch { pipeline.snapshots(options).collect { snapshot -> channel.send(snapshot) } } + + val firstSnapshot = channel.receive() + assertThat(firstSnapshot.metadata.isConsistentBetweenListeners).isFalse() + // only book 9's rating is 4.0 + assertThat(firstSnapshot.results).hasSize(9) + + collRef.document("book9").update("rating", FieldValue.increment(0.001)).await() + + val secondSnapshot = channel.receive() + assertThat(secondSnapshot.metadata.isConsistentBetweenListeners).isFalse() + assertThat(secondSnapshot.results).hasSize(10) + assertThat(secondSnapshot.getChanges()).hasSize(1) + assertThat(secondSnapshot.getChanges()[0].result.get("title")).isEqualTo("The Great Gatsby") + + val thirdSnapshot = channel.receive() + assertThat(thirdSnapshot.metadata.isConsistentBetweenListeners).isTrue() + assertThat(thirdSnapshot.results).hasSize(10) + assertThat(thirdSnapshot.getChanges()).isEmpty() + + job.cancel() + } + + @Test + fun testFloor() = runBlocking { + val pipeline = db.realtimePipeline().collection(collRef.path).where(floor("rating").eq(4.0)) + + val options = RealtimePipelineOptions().withMetadataChanges(MetadataChanges.INCLUDE) + val channel = Channel(Channel.UNLIMITED) + val job = launch { pipeline.snapshots(options).collect { snapshot -> channel.send(snapshot) } } + + val firstSnapshot = channel.receive() + assertThat(firstSnapshot.metadata.isConsistentBetweenListeners).isFalse() + assertThat(firstSnapshot.results).hasSize(10) + + val secondSnapshot = channel.receive() + assertThat(secondSnapshot.metadata.isConsistentBetweenListeners).isTrue() + assertThat(secondSnapshot.results).hasSize(10) + assertThat(secondSnapshot.getChanges()).isEmpty() + + job.cancel() + } + + @Test + fun testTimestampAdd() = runBlocking { + val pipeline = + db + .realtimePipeline() + .collection(eventCollRef.path) + .where( + timestampAdd("timestamp", "day", 1) + .eq(unixSecondsToTimestamp(constant(1698228000 + 24 * 3600))) + ) + + val options = RealtimePipelineOptions().withMetadataChanges(MetadataChanges.INCLUDE) + val channel = Channel(Channel.UNLIMITED) + val job = launch { pipeline.snapshots(options).collect { snapshot -> channel.send(snapshot) } } + + val firstSnapshot = channel.receive() + assertThat(firstSnapshot.metadata.isConsistentBetweenListeners).isFalse() + assertThat(firstSnapshot.results).hasSize(1) + assertThat(firstSnapshot.results[0].get("name")).isEqualTo("Test Event") + + val secondSnapshot = channel.receive() + assertThat(secondSnapshot.metadata.isConsistentBetweenListeners).isTrue() + assertThat(secondSnapshot.results).hasSize(1) + assertThat(secondSnapshot.getChanges()).isEmpty() + + job.cancel() + } + + @Test + fun testTimestampSub() = runBlocking { + val pipeline = + db + .realtimePipeline() + .collection(eventCollRef.path) + .where( + field("timestamp") + .timestampSub("day", 1) + .eq(unixSecondsToTimestamp(constant(1698228000 - 24 * 3600))) + ) + + val options = RealtimePipelineOptions().withMetadataChanges(MetadataChanges.INCLUDE) + val channel = Channel(Channel.UNLIMITED) + val job = launch { pipeline.snapshots(options).collect { snapshot -> channel.send(snapshot) } } + + val firstSnapshot = channel.receive() + assertThat(firstSnapshot.metadata.isConsistentBetweenListeners).isFalse() + assertThat(firstSnapshot.results).hasSize(1) + assertThat(firstSnapshot.results[0].get("name")).isEqualTo("Test Event") + + val secondSnapshot = channel.receive() + assertThat(secondSnapshot.metadata.isConsistentBetweenListeners).isTrue() + assertThat(secondSnapshot.results).hasSize(1) + assertThat(secondSnapshot.getChanges()).isEmpty() + + job.cancel() + } + + @Test + fun testUnixSecondsToTimestamp() = runBlocking { + val pipeline = + db + .realtimePipeline() + .collection(eventCollRef.path) + .where(field("timestamp").eq(unixSecondsToTimestamp(field("unix_seconds")))) + + val options = RealtimePipelineOptions().withMetadataChanges(MetadataChanges.INCLUDE) + val channel = Channel(Channel.UNLIMITED) + val job = launch { pipeline.snapshots(options).collect { snapshot -> channel.send(snapshot) } } + + val firstSnapshot = channel.receive() + assertThat(firstSnapshot.metadata.isConsistentBetweenListeners).isFalse() + assertThat(firstSnapshot.results).hasSize(1) + assertThat(firstSnapshot.results[0].get("name")).isEqualTo("Test Event") + + val secondSnapshot = channel.receive() + assertThat(secondSnapshot.metadata.isConsistentBetweenListeners).isTrue() + assertThat(secondSnapshot.results).hasSize(1) + assertThat(secondSnapshot.getChanges()).isEmpty() + + job.cancel() + } + + @Test + fun testUnixMillisToTimestamp() = runBlocking { + val pipeline = + db + .realtimePipeline() + .collection(eventCollRef.path) + .where(field("timestamp").eq(unixMillisToTimestamp(constant(1698228000000L)))) + + val options = RealtimePipelineOptions().withMetadataChanges(MetadataChanges.INCLUDE) + val channel = Channel(Channel.UNLIMITED) + val job = launch { pipeline.snapshots(options).collect { snapshot -> channel.send(snapshot) } } + + val firstSnapshot = channel.receive() + assertThat(firstSnapshot.metadata.isConsistentBetweenListeners).isFalse() + assertThat(firstSnapshot.results).hasSize(1) + assertThat(firstSnapshot.results[0].get("name")).isEqualTo("Test Event") + + val secondSnapshot = channel.receive() + assertThat(secondSnapshot.metadata.isConsistentBetweenListeners).isTrue() + assertThat(secondSnapshot.results).hasSize(1) + assertThat(secondSnapshot.getChanges()).isEmpty() + + job.cancel() + } + + @Test + fun testTimestampToUnixSeconds() = runBlocking { + val pipeline = + db + .realtimePipeline() + .collection(eventCollRef.path) + .where(timestampToUnixSeconds("timestamp").eq(1698228000)) + + val options = RealtimePipelineOptions().withMetadataChanges(MetadataChanges.INCLUDE) + val channel = Channel(Channel.UNLIMITED) + val job = launch { pipeline.snapshots(options).collect { snapshot -> channel.send(snapshot) } } + + val firstSnapshot = channel.receive() + assertThat(firstSnapshot.metadata.isConsistentBetweenListeners).isFalse() + assertThat(firstSnapshot.results).hasSize(1) + assertThat(firstSnapshot.results[0].get("name")).isEqualTo("Test Event") + + val secondSnapshot = channel.receive() + assertThat(secondSnapshot.metadata.isConsistentBetweenListeners).isTrue() + assertThat(secondSnapshot.results).hasSize(1) + assertThat(secondSnapshot.getChanges()).isEmpty() + + job.cancel() + } + + @Test + fun testTimestampToUnixMillis() = runBlocking { + val pipeline = + db + .realtimePipeline() + .collection(eventCollRef.path) + .where(timestampToUnixMillis("timestamp").eq(field("unix_millis"))) + + val options = RealtimePipelineOptions().withMetadataChanges(MetadataChanges.INCLUDE) + val channel = Channel(Channel.UNLIMITED) + val job = launch { pipeline.snapshots(options).collect { snapshot -> channel.send(snapshot) } } + + val firstSnapshot = channel.receive() + assertThat(firstSnapshot.metadata.isConsistentBetweenListeners).isFalse() + assertThat(firstSnapshot.results).hasSize(1) + assertThat(firstSnapshot.results[0].get("name")).isEqualTo("Test Event") + + val secondSnapshot = channel.receive() + assertThat(secondSnapshot.metadata.isConsistentBetweenListeners).isTrue() + assertThat(secondSnapshot.results).hasSize(1) + assertThat(secondSnapshot.getChanges()).isEmpty() + + job.cancel() + } + + @Test + fun testTimestampToUnixMicros() = runBlocking { + val pipeline = + db + .realtimePipeline() + .collection(eventCollRef.path) + .where(timestampToUnixMicros("timestamp").eq(field("unix_micros"))) + + val options = RealtimePipelineOptions().withMetadataChanges(MetadataChanges.INCLUDE) + val channel = Channel(Channel.UNLIMITED) + val job = launch { pipeline.snapshots(options).collect { snapshot -> channel.send(snapshot) } } + + val firstSnapshot = channel.receive() + assertThat(firstSnapshot.metadata.isConsistentBetweenListeners).isFalse() + assertThat(firstSnapshot.results).hasSize(1) + + val secondSnapshot = channel.receive() + assertThat(secondSnapshot.metadata.isConsistentBetweenListeners).isTrue() + assertThat(secondSnapshot.results).hasSize(1) + assertThat(secondSnapshot.getChanges()).isEmpty() + + job.cancel() + } + + @Test + fun testUnixMicrosToTimestamp() = runBlocking { + val pipeline = + db + .realtimePipeline() + .collection(eventCollRef.path) + .where(field("timestamp").eq(unixMicrosToTimestamp(field("unix_micros")))) + + val options = RealtimePipelineOptions().withMetadataChanges(MetadataChanges.INCLUDE) + val channel = Channel(Channel.UNLIMITED) + val job = launch { pipeline.snapshots(options).collect { snapshot -> channel.send(snapshot) } } + + val firstSnapshot = channel.receive() + assertThat(firstSnapshot.metadata.isConsistentBetweenListeners).isFalse() + assertThat(firstSnapshot.results).hasSize(1) + + val secondSnapshot = channel.receive() + assertThat(secondSnapshot.metadata.isConsistentBetweenListeners).isTrue() + assertThat(secondSnapshot.results).hasSize(1) + assertThat(secondSnapshot.getChanges()).isEmpty() + + job.cancel() + } + + @Test + fun testArrayContains() = runBlocking { + val pipeline = + db.realtimePipeline().collection(collRef.path).where(arrayContains("tags", "politics")) + + val options = RealtimePipelineOptions().withMetadataChanges(MetadataChanges.INCLUDE) + val channel = Channel(Channel.UNLIMITED) + val job = launch { pipeline.snapshots(options).collect { snapshot -> channel.send(snapshot) } } + + val firstSnapshot = channel.receive() + assertThat(firstSnapshot.metadata.isConsistentBetweenListeners).isFalse() + assertThat(firstSnapshot.results).hasSize(1) + assertThat(firstSnapshot.results[0].get("title")).isEqualTo("Dune") + + val secondSnapshot = channel.receive() + assertThat(secondSnapshot.metadata.isConsistentBetweenListeners).isTrue() + assertThat(secondSnapshot.results).hasSize(1) + assertThat(secondSnapshot.getChanges()).isEmpty() + + job.cancel() + } + + @Test + fun testArrayContainsAny() = runBlocking { + val pipeline = + db + .realtimePipeline() + .collection(collRef.path) + .where(arrayContainsAny("tags", listOf("politics", "love", "racism"))) + + val options = RealtimePipelineOptions().withMetadataChanges(MetadataChanges.INCLUDE) + val channel = Channel(Channel.UNLIMITED) + val job = launch { pipeline.snapshots(options).collect { snapshot -> channel.send(snapshot) } } + + val firstSnapshot = channel.receive() + assertThat(firstSnapshot.metadata.isConsistentBetweenListeners).isFalse() + assertThat(firstSnapshot.results).hasSize(4) + // ordered by document id, doc10 goes first. + assertThat(firstSnapshot.results[0].get("title")).isEqualTo("Dune") + assertThat(firstSnapshot.results[1].get("title")).isEqualTo("Pride and Prejudice") + assertThat(firstSnapshot.results[2].get("title")).isEqualTo("To Kill a Mockingbird") + assertThat(firstSnapshot.results[3].get("title")).isEqualTo("The Great Gatsby") + + val secondSnapshot = channel.receive() + assertThat(secondSnapshot.metadata.isConsistentBetweenListeners).isTrue() + assertThat(secondSnapshot.results).hasSize(4) + assertThat(secondSnapshot.getChanges()).isEmpty() + + job.cancel() + } + + @Test + fun testArrayLength() = runBlocking { + val pipeline = db.realtimePipeline().collection(collRef.path).where(arrayLength("tags").eq(3)) + + val options = RealtimePipelineOptions().withMetadataChanges(MetadataChanges.INCLUDE) + val channel = Channel(Channel.UNLIMITED) + val job = launch { pipeline.snapshots(options).collect { snapshot -> channel.send(snapshot) } } + + val firstSnapshot = channel.receive() + assertThat(firstSnapshot.metadata.isConsistentBetweenListeners).isFalse() + assertThat(firstSnapshot.results).hasSize(10) + + val secondSnapshot = channel.receive() + assertThat(secondSnapshot.metadata.isConsistentBetweenListeners).isTrue() + assertThat(secondSnapshot.results).hasSize(10) + assertThat(secondSnapshot.getChanges()).isEmpty() + + job.cancel() + } + + @Test + fun testSubstring() = runBlocking { + val pipeline = + db.realtimePipeline().collection(collRef.path).where(field("title").substr(1, 3).eq("he ")) + + val options = RealtimePipelineOptions().withMetadataChanges(MetadataChanges.INCLUDE) + val channel = Channel(Channel.UNLIMITED) + val job = launch { pipeline.snapshots(options).collect { snapshot -> channel.send(snapshot) } } + + val firstSnapshot = channel.receive() + assertThat(firstSnapshot.metadata.isConsistentBetweenListeners).isFalse() + // Any title starts with "The " + assertThat(firstSnapshot.results).hasSize(4) + assertThat(firstSnapshot.results.map { it.get("title").toString().startsWith("The ") }) + .isEqualTo(listOf(true, true, true, true)) + + val secondSnapshot = channel.receive() + assertThat(secondSnapshot.metadata.isConsistentBetweenListeners).isTrue() + assertThat(secondSnapshot.results).hasSize(4) + assertThat(secondSnapshot.getChanges()).isEmpty() + + job.cancel() + } +} 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 674789546f4..92628527d25 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 @@ -100,7 +100,7 @@ public enum TargetBackend { // 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 TargetBackend backendForLocalTesting = TargetBackend.EMULATOR; private static final TargetBackend backend = getTargetBackend(); private static final String EMULATOR_HOST = "10.0.2.2"; diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/DocumentChange.java b/firebase-firestore/src/main/java/com/google/firebase/firestore/DocumentChange.java index b2d11896b8c..047becc3fe4 100644 --- a/firebase-firestore/src/main/java/com/google/firebase/firestore/DocumentChange.java +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/DocumentChange.java @@ -14,17 +14,9 @@ package com.google.firebase.firestore; -import static com.google.firebase.firestore.util.Assert.hardAssert; - import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; -import com.google.firebase.firestore.core.DocumentViewChange; -import com.google.firebase.firestore.core.ViewSnapshot; -import com.google.firebase.firestore.model.Document; -import com.google.firebase.firestore.model.DocumentSet; -import java.util.ArrayList; -import java.util.List; /** * A {@code DocumentChange} represents a change to the documents matching a query. It contains the @@ -122,83 +114,4 @@ public int getOldIndex() { public int getNewIndex() { return newIndex; } - - /** Creates the list of document changes from a {@code ViewSnapshot}. */ - static List changesFromSnapshot( - FirebaseFirestore firestore, MetadataChanges metadataChanges, ViewSnapshot snapshot) { - List documentChanges = new ArrayList<>(); - if (snapshot.getOldDocuments().isEmpty()) { - // Special case the first snapshot because index calculation is easy and fast. Also all - // changes on the first snapshot are adds so there are also no metadata-only changes to filter - // out. - int index = 0; - Document lastDoc = null; - for (DocumentViewChange change : snapshot.getChanges()) { - Document document = change.getDocument(); - QueryDocumentSnapshot documentSnapshot = - QueryDocumentSnapshot.fromDocument( - firestore, - document, - snapshot.isFromCache(), - snapshot.getMutatedKeys().contains(document.getKey())); - hardAssert( - change.getType() == DocumentViewChange.Type.ADDED, - "Invalid added event for first snapshot"); - hardAssert( - lastDoc == null || snapshot.getQuery().comparator().compare(lastDoc, document) < 0, - "Got added events in wrong order"); - documentChanges.add(new DocumentChange(documentSnapshot, Type.ADDED, -1, index++)); - lastDoc = document; - } - } else { - // A DocumentSet that is updated incrementally as changes are applied to use to lookup the - // index of a document. - DocumentSet indexTracker = snapshot.getOldDocuments(); - for (DocumentViewChange change : snapshot.getChanges()) { - if (metadataChanges == MetadataChanges.EXCLUDE - && change.getType() == DocumentViewChange.Type.METADATA) { - continue; - } - Document document = change.getDocument(); - QueryDocumentSnapshot documentSnapshot = - QueryDocumentSnapshot.fromDocument( - firestore, - document, - snapshot.isFromCache(), - snapshot.getMutatedKeys().contains(document.getKey())); - int oldIndex, newIndex; - Type type = getType(change); - if (type != Type.ADDED) { - oldIndex = indexTracker.indexOf(document.getKey()); - hardAssert(oldIndex >= 0, "Index for document not found"); - indexTracker = indexTracker.remove(document.getKey()); - } else { - oldIndex = -1; - } - if (type != Type.REMOVED) { - indexTracker = indexTracker.add(document); - newIndex = indexTracker.indexOf(document.getKey()); - hardAssert(newIndex >= 0, "Index for document not found"); - } else { - newIndex = -1; - } - documentChanges.add(new DocumentChange(documentSnapshot, type, oldIndex, newIndex)); - } - } - return documentChanges; - } - - private static Type getType(DocumentViewChange change) { - switch (change.getType()) { - case ADDED: - return Type.ADDED; - case METADATA: - case MODIFIED: - return Type.MODIFIED; - case REMOVED: - return Type.REMOVED; - default: - throw new IllegalArgumentException("Unknown view change type: " + change.getType()); - } - } } 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 e0094a46fa8..f852b8549d7 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 @@ -17,10 +17,8 @@ package com.google.firebase.firestore import com.google.android.gms.tasks.Task import com.google.android.gms.tasks.TaskCompletionSource import com.google.firebase.Timestamp -import com.google.firebase.firestore.core.Canonicalizable import com.google.firebase.firestore.model.Document import com.google.firebase.firestore.model.DocumentKey -import com.google.firebase.firestore.model.MutableDocument import com.google.firebase.firestore.model.ResourcePath import com.google.firebase.firestore.model.Values import com.google.firebase.firestore.pipeline.AddFieldsStage @@ -33,7 +31,6 @@ import com.google.firebase.firestore.pipeline.CollectionSource import com.google.firebase.firestore.pipeline.DatabaseSource import com.google.firebase.firestore.pipeline.DistinctStage import com.google.firebase.firestore.pipeline.DocumentsSource -import com.google.firebase.firestore.pipeline.EvaluationContext import com.google.firebase.firestore.pipeline.Expr import com.google.firebase.firestore.pipeline.Expr.Companion.field import com.google.firebase.firestore.pipeline.ExprWithAlias @@ -55,8 +52,6 @@ import com.google.firebase.firestore.pipeline.Stage import com.google.firebase.firestore.pipeline.UnionStage 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.Assert.fail import com.google.firestore.v1.ExecutePipelineRequest import com.google.firestore.v1.StructuredPipeline import com.google.firestore.v1.Value @@ -117,7 +112,6 @@ internal constructor( ) { results.add( PipelineResult( - firestore!!, userDataWriter, if (key == null) null else DocumentReference(key, firestore), data, @@ -704,217 +698,6 @@ class PipelineSource internal constructor(private val firestore: FirebaseFiresto } } -class RealtimePipelineSource internal constructor(private val firestore: FirebaseFirestore) { - /** - * Convert the given Query into an equivalent Pipeline. - * - * @param query A Query to be converted into a Pipeline. - * @return A new [Pipeline] object that is equivalent to [query] - * @throws [IllegalArgumentException] Thrown if the [query] provided targets a different project - * or database than the pipeline. - */ - fun convertFrom(query: Query): RealtimePipeline { - if (query.firestore.databaseId != firestore.databaseId) { - throw IllegalArgumentException("Provided query is from a different Firestore instance.") - } - return query.query.toRealtimePipeline(firestore, firestore.userDataReader) - } - - /** - * Set the pipeline's source to the collection specified by the given path. - * - * @param path A path to a collection that will be the source of this pipeline. - * @return A new [RealtimePipeline] object with documents from target collection. - */ - fun collection(path: String): RealtimePipeline = collection(firestore.collection(path)) - - /** - * Set the pipeline's source to the collection specified by the given [CollectionReference]. - * - * @param ref A [CollectionReference] for a collection that will be the source of this pipeline. - * @return A new [RealtimePipeline] object with documents from target collection. - * @throws [IllegalArgumentException] Thrown if the [ref] provided targets a different project or - * database than the pipeline. - */ - fun collection(ref: CollectionReference): RealtimePipeline = - collection(CollectionSource.of(ref, firestore.databaseId)) - - /** - * Set the pipeline's source to the collection specified by CollectionSource. - * - * @param stage A [CollectionSource] that will be the source of this pipeline. - * @return A new [RealtimePipeline] object with documents from target collection. - * @throws [IllegalArgumentException] Thrown if the [stage] provided targets a different project - * or database than the pipeline. - */ - fun collection(stage: CollectionSource): RealtimePipeline { - if (stage.serializer.databaseId() != firestore.databaseId) { - throw IllegalArgumentException("Provided collection is from a different Firestore instance.") - } - return RealtimePipeline(RemoteSerializer(firestore.databaseId), firestore.userDataReader, stage) - } - - /** - * Set the pipeline's source to the collection group with the given id. - * - * @param collectionId The id of a collection group that will be the source of this pipeline. - * @return A new [RealtimePipeline] object with documents from target collection group. - */ - fun collectionGroup(collectionId: String): RealtimePipeline = - collectionGroup(CollectionGroupSource.of((collectionId))) - - fun collectionGroup(stage: CollectionGroupSource): RealtimePipeline = - RealtimePipeline(RemoteSerializer(firestore.databaseId), firestore.userDataReader, stage) -} - -class RealtimePipeline -internal constructor( - internal val serializer: RemoteSerializer, - internal val userDataReader: UserDataReader, - internal val stages: List> -) : Canonicalizable { - internal constructor( - serializer: RemoteSerializer, - userDataReader: UserDataReader, - stage: Stage<*> - ) : this(serializer, userDataReader, listOf(stage)) - - private fun with(stages: List>): RealtimePipeline = - RealtimePipeline(serializer, userDataReader, stages) - - private fun append(stage: Stage<*>): RealtimePipeline = with(stages.plus(stage)) - - fun limit(limit: Int): RealtimePipeline = append(LimitStage(limit)) - - fun offset(offset: Int): RealtimePipeline = append(OffsetStage(offset)) - - fun select(selection: Selectable, vararg additionalSelections: Any): RealtimePipeline = - append(SelectStage.of(selection, *additionalSelections)) - - fun select(fieldName: String, vararg additionalSelections: Any): RealtimePipeline = - append(SelectStage.of(fieldName, *additionalSelections)) - - fun sort(order: Ordering, vararg additionalOrders: Ordering): RealtimePipeline = - append(SortStage(arrayOf(order, *additionalOrders))) - - fun where(condition: BooleanExpr): RealtimePipeline = append(WhereStage(condition)) - - internal val rewrittenStages: List> by lazy { - var hasOrder = false - buildList { - for (stage in stages) when (stage) { - // Stages whose semantics depend on ordering - is LimitStage, - is OffsetStage -> { - if (!hasOrder) { - hasOrder = true - add(SortStage.BY_DOCUMENT_ID) - } - add(stage) - } - is SortStage -> { - hasOrder = true - add(stage.withStableOrdering()) - } - else -> add(stage) - } - if (!hasOrder) { - add(SortStage.BY_DOCUMENT_ID) - } - } - } - - override fun canonicalId(): String { - return rewrittenStages.joinToString("|") { stage -> (stage as Canonicalizable).canonicalId() } - } - - override fun toString(): String = canonicalId() - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other !is RealtimePipeline) return false - if (serializer.databaseId() != other.serializer.databaseId()) return false - return rewrittenStages == other.rewrittenStages - } - - override fun hashCode(): Int { - return serializer.databaseId().hashCode() * 31 + stages.hashCode() - } - - internal fun evaluate(inputs: List): List { - val context = EvaluationContext(this) - return rewrittenStages.fold(inputs) { documents, stage -> stage.evaluate(context, documents) } - } - - internal fun matchesAllDocuments(): Boolean { - for (stage in rewrittenStages) { - // Check for LimitStage - if (stage.name == "limit") { - return false - } - - // Check for Where stage - if (stage is WhereStage) { - // Check if it's the special 'exists(__name__)' case - val funcExpr = stage.condition as? FunctionExpr - if (funcExpr?.name == "exists" && funcExpr.params.size == 1) { - val fieldExpr = funcExpr.params[0] as? Field - if (fieldExpr?.fieldPath?.isKeyField == true) { - continue // This specific 'exists(__name__)' filter doesn't count - } - } - return false - } - // TODO(pipeline) : Add checks for other filtering stages like Aggregate, - // Distinct, FindNearest once they are implemented. - } - return true - } - - internal fun hasLimit(): Boolean { - for (stage in rewrittenStages) { - if (stage.name == "limit") { - return true - } - // TODO(pipeline): need to check for other stages that could have a limit, - // like findNearest - } - return false - } - - internal fun matches(doc: Document): Boolean { - val result = evaluate(listOf(doc as MutableDocument)) - return result.isNotEmpty() - } - - private fun evaluateContext(): EvaluationContext { - return EvaluationContext(this) - } - - internal fun comparator(): Comparator = - getLastEffectiveSortStage().comparator(evaluateContext()) - - internal fun toStructurePipelineProto(): StructuredPipeline { - val builder = StructuredPipeline.newBuilder() - builder.pipeline = - com.google.firestore.v1.Pipeline.newBuilder() - .addAllStages(rewrittenStages.map { it.toProtoStage(userDataReader) }) - .build() - return builder.build() - } - - private fun getLastEffectiveSortStage(): SortStage { - for (stage in rewrittenStages.asReversed()) { - if (stage is SortStage) { - return stage - } - // TODO(pipeline): Consider stages that might invalidate ordering later, - // like fineNearest - } - throw fail("RealtimePipeline must contain at least one Sort stage (ensured by RewriteStages).") - } -} - /** */ class PipelineSnapshot @@ -932,7 +715,6 @@ internal constructor(executionTime: Timestamp, results: List) : class PipelineResult internal constructor( - private val firestore: FirebaseFirestore, private val userDataWriter: UserDataWriter, ref: DocumentReference?, private val fields: Map, @@ -940,6 +722,18 @@ internal constructor( 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 diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/QuerySnapshot.java b/firebase-firestore/src/main/java/com/google/firebase/firestore/QuerySnapshot.java index e85868943c3..6a01c0ffd51 100644 --- a/firebase-firestore/src/main/java/com/google/firebase/firestore/QuerySnapshot.java +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/QuerySnapshot.java @@ -14,6 +14,7 @@ package com.google.firebase.firestore; +import static com.google.firebase.firestore.RealtimePipelineKt.changesFromSnapshot; import static com.google.firebase.firestore.util.Preconditions.checkNotNull; import androidx.annotation.NonNull; @@ -119,7 +120,18 @@ public List getDocumentChanges(@NonNull MetadataChanges metadata if (cachedChanges == null || cachedChangesMetadataState != metadataChanges) { cachedChanges = Collections.unmodifiableList( - DocumentChange.changesFromSnapshot(firestore, metadataChanges, snapshot)); + changesFromSnapshot( + metadataChanges, + snapshot, + (doc, type, oldIndex, newIndex) -> { + QueryDocumentSnapshot documentSnapshot = + QueryDocumentSnapshot.fromDocument( + firestore, + doc, + snapshot.isFromCache(), + snapshot.getMutatedKeys().contains(doc.getKey())); + return new DocumentChange(documentSnapshot, type, oldIndex, newIndex); + })); cachedChangesMetadataState = metadataChanges; } return cachedChanges; diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/RealtimePipeline.kt b/firebase-firestore/src/main/java/com/google/firebase/firestore/RealtimePipeline.kt new file mode 100644 index 00000000000..e90fe69b1c6 --- /dev/null +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/RealtimePipeline.kt @@ -0,0 +1,569 @@ +// 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.firebase.firestore.core.AsyncEventListener +import com.google.firebase.firestore.core.Canonicalizable +import com.google.firebase.firestore.core.DocumentViewChange +import com.google.firebase.firestore.core.EventManager +import com.google.firebase.firestore.core.QueryListener +import com.google.firebase.firestore.core.QueryOrPipeline +import com.google.firebase.firestore.core.ViewSnapshot +import com.google.firebase.firestore.model.Document +import com.google.firebase.firestore.model.MutableDocument +import com.google.firebase.firestore.pipeline.BooleanExpr +import com.google.firebase.firestore.pipeline.CollectionGroupSource +import com.google.firebase.firestore.pipeline.CollectionSource +import com.google.firebase.firestore.pipeline.EvaluationContext +import com.google.firebase.firestore.pipeline.Field +import com.google.firebase.firestore.pipeline.FunctionExpr +import com.google.firebase.firestore.pipeline.InternalOptions +import com.google.firebase.firestore.pipeline.LimitStage +import com.google.firebase.firestore.pipeline.OffsetStage +import com.google.firebase.firestore.pipeline.Ordering +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.WhereStage +import com.google.firebase.firestore.remote.RemoteSerializer +import com.google.firebase.firestore.util.Assert +import com.google.firebase.firestore.util.Assert.fail +import com.google.firebase.firestore.util.Executors +import com.google.firestore.v1.StructuredPipeline +import java.util.concurrent.Executor +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow + +class RealtimePipelineSource internal constructor(private val firestore: FirebaseFirestore) { + /** + * Convert the given Query into an equivalent Pipeline. + * + * @param query A Query to be converted into a Pipeline. + * @return A new [Pipeline] object that is equivalent to [query] + * @throws [IllegalArgumentException] Thrown if the [query] provided targets a different project + * or database than the pipeline. + */ + fun convertFrom(query: Query): RealtimePipeline { + if (query.firestore.databaseId != firestore.databaseId) { + throw IllegalArgumentException("Provided query is from a different Firestore instance.") + } + return query.query.toRealtimePipeline(firestore, firestore.userDataReader) + } + + /** + * Set the pipeline's source to the collection specified by the given path. + * + * @param path A path to a collection that will be the source of this pipeline. + * @return A new [RealtimePipeline] object with documents from target collection. + */ + fun collection(path: String): RealtimePipeline = collection(firestore.collection(path)) + + /** + * Set the pipeline's source to the collection specified by the given [CollectionReference]. + * + * @param ref A [CollectionReference] for a collection that will be the source of this pipeline. + * @return A new [RealtimePipeline] object with documents from target collection. + * @throws [IllegalArgumentException] Thrown if the [ref] provided targets a different project or + * database than the pipeline. + */ + fun collection(ref: CollectionReference): RealtimePipeline = + collection(CollectionSource.of(ref, firestore.databaseId)) + + /** + * Set the pipeline's source to the collection specified by CollectionSource. + * + * @param stage A [CollectionSource] that will be the source of this pipeline. + * @return A new [RealtimePipeline] object with documents from target collection. + * @throws [IllegalArgumentException] Thrown if the [stage] provided targets a different project + * or database than the pipeline. + */ + fun collection(stage: CollectionSource): RealtimePipeline { + if (stage.serializer.databaseId() != firestore.databaseId) { + throw IllegalArgumentException("Provided collection is from a different Firestore instance.") + } + return RealtimePipeline( + firestore, + RemoteSerializer(firestore.databaseId), + firestore.userDataReader, + stage + ) + } + + /** + * Set the pipeline's source to the collection group with the given id. + * + * @param collectionId The id of a collection group that will be the source of this pipeline. + * @return A new [RealtimePipeline] object with documents from target collection group. + */ + fun collectionGroup(collectionId: String): RealtimePipeline = + collectionGroup(CollectionGroupSource.of((collectionId))) + + fun collectionGroup(stage: CollectionGroupSource): RealtimePipeline = + RealtimePipeline( + firestore, + RemoteSerializer(firestore.databaseId), + firestore.userDataReader, + stage + ) +} + +class RealtimePipeline +internal constructor( + // This is nullable because RealtimePipeline is also created from deserialization from persistent + // cache. In that case, it is only used to facilitate remote store requests, and this field is + // never used in that scenario. + internal val firestore: FirebaseFirestore?, + internal val serializer: RemoteSerializer, + internal val userDataReader: UserDataReader, + internal val stages: List>, + internal val internalOptions: EventManager.ListenOptions? = null +) : Canonicalizable { + internal constructor( + firestore: FirebaseFirestore, + serializer: RemoteSerializer, + userDataReader: UserDataReader, + stage: Stage<*> + ) : this(firestore, serializer, userDataReader, listOf(stage)) + + private fun with(stages: List>): RealtimePipeline = + RealtimePipeline(firestore, serializer, userDataReader, stages) + + private fun append(stage: Stage<*>): RealtimePipeline = with(stages.plus(stage)) + + fun limit(limit: Int): RealtimePipeline = append(LimitStage(limit)) + + fun offset(offset: Int): RealtimePipeline = append(OffsetStage(offset)) + + fun select(selection: Selectable, vararg additionalSelections: Any): RealtimePipeline = + append(SelectStage.of(selection, *additionalSelections)) + + fun select(fieldName: String, vararg additionalSelections: Any): RealtimePipeline = + append(SelectStage.of(fieldName, *additionalSelections)) + + fun sort(order: Ordering, vararg additionalOrders: Ordering): RealtimePipeline = + append(SortStage(arrayOf(order, *additionalOrders))) + + fun where(condition: BooleanExpr): RealtimePipeline = append(WhereStage(condition)) + + fun snapshots(): Flow = snapshots(RealtimePipelineOptions.DEFAULT) + + fun snapshots(options: RealtimePipelineOptions): Flow = callbackFlow { + val listener = + addSnapshotListener(options) { snapshot, error -> + if (snapshot != null) { + trySend(snapshot) + } else { + close(error) + } + } + awaitClose { listener.remove() } + } + + fun addSnapshotListener(listener: EventListener): ListenerRegistration = + addSnapshotListener(RealtimePipelineOptions.DEFAULT, listener) + + fun addSnapshotListener( + options: RealtimePipelineOptions, + listener: EventListener + ): ListenerRegistration = + addSnapshotListener(Executors.DEFAULT_CALLBACK_EXECUTOR, options, listener) + + fun addSnapshotListener( + executor: Executor, + listener: EventListener + ): ListenerRegistration = addSnapshotListener(executor, RealtimePipelineOptions.DEFAULT, listener) + + fun addSnapshotListener( + executor: Executor, + options: RealtimePipelineOptions, + listener: EventListener + ): ListenerRegistration { + val userListener = + EventListener { snapshot, error -> + val realtimeSnapshot = snapshot?.let { RealtimePipelineSnapshot(it, firestore!!, options) } + listener.onEvent(realtimeSnapshot, error) + } + + val asyncListener = AsyncEventListener(executor, userListener) + + return firestore!!.callClient { client -> + val listener: QueryListener = + client!!.listen( + QueryOrPipeline.PipelineWrapper(this), + options.toListenOptions(), + asyncListener + ) + ListenerRegistration { + asyncListener.mute() + client!!.stopListening(listener) + } + } + } + + internal fun withListenOptions(options: EventManager.ListenOptions): RealtimePipeline = + RealtimePipeline(firestore, serializer, userDataReader, stages, options) + + internal val rewrittenStages: List> by lazy { + var hasOrder = false + buildList { + for (stage in stages) when (stage) { + // Stages whose semantics depend on ordering + is LimitStage, + is OffsetStage -> { + if (!hasOrder) { + hasOrder = true + add(SortStage.BY_DOCUMENT_ID) + } + add(stage) + } + is SortStage -> { + hasOrder = true + add(stage.withStableOrdering()) + } + else -> add(stage) + } + if (!hasOrder) { + add(SortStage.BY_DOCUMENT_ID) + } + } + } + + override fun canonicalId(): String { + return rewrittenStages.joinToString("|") { stage -> (stage as Canonicalizable).canonicalId() } + } + + override fun toString(): String = canonicalId() + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is RealtimePipeline) return false + if (serializer.databaseId() != other.serializer.databaseId()) return false + return rewrittenStages == other.rewrittenStages + } + + override fun hashCode(): Int { + return serializer.databaseId().hashCode() * 31 + stages.hashCode() + } + + internal fun evaluate(inputs: List): List { + val context = EvaluationContext(this) + return rewrittenStages.fold(inputs) { documents, stage -> stage.evaluate(context, documents) } + } + + internal fun matchesAllDocuments(): Boolean { + for (stage in rewrittenStages) { + // Check for LimitStage + if (stage.name == "limit") { + return false + } + + // Check for Where stage + if (stage is WhereStage) { + // Check if it's the special 'exists(__name__)' case + val funcExpr = stage.condition as? FunctionExpr + if (funcExpr?.name == "exists" && funcExpr.params.size == 1) { + val fieldExpr = funcExpr.params[0] as? Field + if (fieldExpr?.fieldPath?.isKeyField == true) { + continue // This specific 'exists(__name__)' filter doesn't count + } + } + return false + } + // TODO(pipeline) : Add checks for other filtering stages like Aggregate, + // Distinct, FindNearest once they are implemented. + } + return true + } + + internal fun hasLimit(): Boolean { + for (stage in rewrittenStages) { + if (stage.name == "limit") { + return true + } + // TODO(pipeline): need to check for other stages that could have a limit, + // like findNearest + } + return false + } + + internal fun matches(doc: Document): Boolean { + val result = evaluate(listOf(doc as MutableDocument)) + return result.isNotEmpty() + } + + private fun evaluateContext(): EvaluationContext { + return EvaluationContext(this) + } + + internal fun comparator(): Comparator = + getLastEffectiveSortStage().comparator(evaluateContext()) + + internal fun toStructurePipelineProto(): StructuredPipeline { + val builder = StructuredPipeline.newBuilder() + builder.pipeline = + com.google.firestore.v1.Pipeline.newBuilder() + .addAllStages(rewrittenStages.map { it.toProtoStage(userDataReader) }) + .build() + return builder.build() + } + + private fun getLastEffectiveSortStage(): SortStage { + for (stage in rewrittenStages.asReversed()) { + if (stage is SortStage) { + return stage + } + // TODO(pipeline): Consider stages that might invalidate ordering later, + // like fineNearest + } + throw fail("RealtimePipeline must contain at least one Sort stage (ensured by RewriteStages).") + } +} + +/** + * An options object that configures the behavior of `snapshots()` calls. By default, `snapshots()` + * attempts to provide up-to-date data when possible, but falls back to cached data if the device is + * offline and the server cannot be reached. + */ +class RealtimePipelineOptions +private constructor( + internal val source: ListenSource, + internal val serverTimestampBehavior: DocumentSnapshot.ServerTimestampBehavior, + internal val metadataChanges: MetadataChanges, + options: InternalOptions +) { + + constructor() : + this( + ListenSource.DEFAULT, + DocumentSnapshot.ServerTimestampBehavior.NONE, + MetadataChanges.EXCLUDE, + InternalOptions.EMPTY + ) + + companion object { + /** A `RealtimePipelineOptions` object with default options. */ + @JvmField + val DEFAULT: RealtimePipelineOptions = + RealtimePipelineOptions( + ListenSource.DEFAULT, + DocumentSnapshot.ServerTimestampBehavior.NONE, + MetadataChanges.EXCLUDE, + InternalOptions.EMPTY + ) + } + + /** + * Returns a new `RealtimePipelineOptions` object with the specified `ListenSource`. + * + * @param source The `ListenSource` to use. + * @return A new `RealtimePipelineOptions` object. + */ + fun withSource(source: ListenSource): RealtimePipelineOptions { + return RealtimePipelineOptions( + source, + serverTimestampBehavior, + metadataChanges, + InternalOptions.EMPTY + ) + } + + /** + * Returns a new `RealtimePipelineOptions` object with the specified `ServerTimestampBehavior`. + * + * @param serverTimestampBehavior The `ServerTimestampBehavior` to use. + * @return A new `RealtimePipelineOptions` object. + */ + fun withServerTimestampBehavior( + serverTimestampBehavior: DocumentSnapshot.ServerTimestampBehavior + ): RealtimePipelineOptions { + return RealtimePipelineOptions( + source, + serverTimestampBehavior, + metadataChanges, + InternalOptions.EMPTY + ) + } + + /** + * Returns a new `RealtimePipelineOptions` object with the specified `MetadataChanges` option. + * + * @param metadataChanges The `MetadataChanges` option to use. + * @return A new `RealtimePipelineOptions` object. + */ + fun withMetadataChanges(metadataChanges: MetadataChanges): RealtimePipelineOptions { + return RealtimePipelineOptions( + source, + serverTimestampBehavior, + metadataChanges, + InternalOptions.EMPTY + ) + } + + internal fun toListenOptions(): EventManager.ListenOptions { + val result = EventManager.ListenOptions() + result.source = source + result.includeQueryMetadataChanges = metadataChanges == MetadataChanges.INCLUDE + result.includeDocumentMetadataChanges = metadataChanges == MetadataChanges.INCLUDE + result.waitForSyncWhenOnline = false + result.serverTimestampBehavior = serverTimestampBehavior + return result + } +} + +class RealtimePipelineSnapshot +internal constructor( + private val viewSnapshot: ViewSnapshot, + private val firestore: FirebaseFirestore, + private val options: RealtimePipelineOptions +) { + val metadata: PipelineSnapshotMetadata + get() = PipelineSnapshotMetadata(viewSnapshot.hasPendingWrites(), !viewSnapshot.isFromCache) + + val results: List + get() = + viewSnapshot.documents.map { PipelineResult(it, options.serverTimestampBehavior, firestore) } + + fun getChanges(metadataChanges: MetadataChanges? = null): List = + changesFromSnapshot(metadataChanges ?: MetadataChanges.EXCLUDE, viewSnapshot) { + doc, + type, + oldIndex, + newIndex -> + PipelineResultChange( + firestore, + doc, + options.serverTimestampBehavior, + type, + oldIndex, + newIndex + ) + } +} + +data class PipelineSnapshotMetadata +internal constructor(val hasPendingWrites: Boolean, val isConsistentBetweenListeners: Boolean) + +data class PipelineResultChange +internal constructor( + val result: PipelineResult, + val type: ChangeType, + val oldIndex: Int?, + val newIndex: Int? +) { + enum class ChangeType { + ADDED, + MODIFIED, + REMOVED + } + + internal constructor( + firestore: FirebaseFirestore, + doc: Document, + serverTimestampBehavior: DocumentSnapshot.ServerTimestampBehavior, + type: DocumentChange.Type, + oldIndex: Int, + newIndex: Int + ) : this( + PipelineResult(doc, serverTimestampBehavior, firestore), + getChangeType(type), + oldIndex, + newIndex + ) + + companion object { + private fun getChangeType(type: DocumentChange.Type): ChangeType = + when (type) { + DocumentChange.Type.ADDED -> ChangeType.ADDED + DocumentChange.Type.MODIFIED -> ChangeType.MODIFIED + DocumentChange.Type.REMOVED -> ChangeType.REMOVED + } + } +} + +/** Creates the list of document changes from a `ViewSnapshot`. */ +internal fun changesFromSnapshot( + metadataChanges: MetadataChanges, + snapshot: ViewSnapshot, + fromDocument: (Document, DocumentChange.Type, Int, Int) -> T +): List { + val documentChanges: MutableList = ArrayList() + if (snapshot.getOldDocuments().isEmpty()) { + // Special case the first snapshot because index calculation is easy and fast. Also all + // changes on the first snapshot are adds so there are also no metadata-only changes to filter + // out. + var index = 0 + var lastDoc: Document? = null + for (change in snapshot.getChanges()) { + val document = change.getDocument() + Assert.hardAssert( + change.getType() == DocumentViewChange.Type.ADDED, + "Invalid added event for first snapshot" + ) + Assert.hardAssert( + lastDoc == null || snapshot.getQuery().comparator().compare(lastDoc, document) < 0, + "Got added events in wrong order" + ) + + documentChanges.add(fromDocument(document, DocumentChange.Type.ADDED, -1, index++)) + lastDoc = document + } + } else { + // A DocumentSet that is updated incrementally as changes are applied to use to lookup the + // index of a document. + var indexTracker = snapshot.getOldDocuments() + for (change in snapshot.getChanges()) { + if ( + metadataChanges == MetadataChanges.EXCLUDE && + change.getType() == DocumentViewChange.Type.METADATA + ) { + continue + } + val document = change.getDocument() + val oldIndex: Int + val newIndex: Int + val type = getType(change) + if (type != DocumentChange.Type.ADDED) { + oldIndex = indexTracker.indexOf(document.getKey()) + Assert.hardAssert(oldIndex >= 0, "Index for document not found") + indexTracker = indexTracker.remove(document.getKey()) + } else { + oldIndex = -1 + } + if (type != DocumentChange.Type.REMOVED) { + indexTracker = indexTracker.add(document) + newIndex = indexTracker.indexOf(document.getKey()) + Assert.hardAssert(newIndex >= 0, "Index for document not found") + } else { + newIndex = -1 + } + + documentChanges.add(fromDocument(document, type, oldIndex, newIndex)) + } + } + return documentChanges +} + +private fun getType(change: DocumentViewChange): DocumentChange.Type { + when (change.getType()) { + DocumentViewChange.Type.ADDED -> return DocumentChange.Type.ADDED + DocumentViewChange.Type.METADATA, + DocumentViewChange.Type.MODIFIED -> return DocumentChange.Type.MODIFIED + DocumentViewChange.Type.REMOVED -> return DocumentChange.Type.REMOVED + else -> + throw java.lang.IllegalArgumentException("Unknown view change type: " + change.getType()) + } +} diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/core/EventManager.java b/firebase-firestore/src/main/java/com/google/firebase/firestore/core/EventManager.java index d5f00222bd0..39879e23dc6 100644 --- a/firebase-firestore/src/main/java/com/google/firebase/firestore/core/EventManager.java +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/core/EventManager.java @@ -16,6 +16,7 @@ import static com.google.firebase.firestore.util.Assert.hardAssert; +import com.google.firebase.firestore.DocumentSnapshot; import com.google.firebase.firestore.EventListener; import com.google.firebase.firestore.ListenSource; import com.google.firebase.firestore.core.SyncEngine.SyncEngineCallback; @@ -67,6 +68,9 @@ public static class ListenOptions { /** Sets the source the query listens to. */ public ListenSource source = ListenSource.DEFAULT; + + public DocumentSnapshot.ServerTimestampBehavior serverTimestampBehavior = + DocumentSnapshot.ServerTimestampBehavior.NONE; } private final SyncEngine syncEngine; diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/core/PipelineUtil.kt b/firebase-firestore/src/main/java/com/google/firebase/firestore/core/PipelineUtil.kt index 1d215be76f8..f8a07a2ca53 100644 --- a/firebase-firestore/src/main/java/com/google/firebase/firestore/core/PipelineUtil.kt +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/core/PipelineUtil.kt @@ -19,13 +19,15 @@ package com.google.firebase.firestore.core import com.google.firebase.firestore.RealtimePipeline import com.google.firebase.firestore.model.Document import com.google.firebase.firestore.model.ResourcePath -import com.google.firebase.firestore.model.Values import com.google.firebase.firestore.pipeline.CollectionGroupSource import com.google.firebase.firestore.pipeline.CollectionSource import com.google.firebase.firestore.pipeline.DatabaseSource import com.google.firebase.firestore.pipeline.DocumentsSource import com.google.firebase.firestore.pipeline.InternalOptions import com.google.firebase.firestore.pipeline.LimitStage +import com.google.firebase.firestore.pipeline.Ordering +import com.google.firebase.firestore.pipeline.SortStage +import com.google.firebase.firestore.util.Assert import com.google.firebase.firestore.util.Assert.hardAssert /** A class that wraps either a Query or a RealtimePipeline. */ @@ -262,6 +264,7 @@ fun asCollectionPipelineAtPath( // Construct a new RealtimePipeline with the (potentially) modified stages // and the original user_data_reader. return RealtimePipeline( + pipeline.firestore, pipeline.serializer, pipeline.userDataReader, newStages, @@ -283,12 +286,11 @@ fun getLastEffectiveLimit(pipeline: RealtimePipeline): Int? { private fun getLastEffectiveSortOrderings(pipeline: RealtimePipeline): List { for (stage in pipeline.rewrittenStages.asReversed()) { if (stage is SortStage) { - return stage.orders + return stage.orders.toList() } // TODO(pipeline): Consider stages that might invalidate ordering later, // like fineNearest } - HardAssert.hardFail( - "RealtimePipeline must contain at least one Sort stage (ensured by RewriteStages)." - ) + Assert.fail("RealtimePipeline must contain at least one Sort stage (ensured by RewriteStages).") + return emptyList() } diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/core/Query.java b/firebase-firestore/src/main/java/com/google/firebase/firestore/core/Query.java index 8b7eee25a81..8b6e23b4112 100644 --- a/firebase-firestore/src/main/java/com/google/firebase/firestore/core/Query.java +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/core/Query.java @@ -537,9 +537,11 @@ public Pipeline toPipeline(FirebaseFirestore firestore, UserDataReader userDataR public RealtimePipeline toRealtimePipeline( FirebaseFirestore firestore, UserDataReader userDataReader) { return new RealtimePipeline( + firestore, new RemoteSerializer(userDataReader.getDatabaseId()), userDataReader, - convertToStages(userDataReader)); + convertToStages(userDataReader), + null); } private List> convertToStages(UserDataReader userDataReader) { @@ -593,6 +595,11 @@ private List> convertToStages(UserDataReader userDataReader) { stages.add(new SortStage(orderings.toArray(new Ordering[0]), InternalOptions.EMPTY)); stages.add(new LimitStage((int) limit, InternalOptions.EMPTY)); } else { + if (explicitSortOrder.isEmpty()) { + throw new IllegalStateException( + "limitToLast() queries require specifying at least one orderBy() clause"); + } + List reversedOrderings = new ArrayList<>(); for (Ordering ordering : orderings) { reversedOrderings.add(ordering.reverse()); diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/core/QueryListener.java b/firebase-firestore/src/main/java/com/google/firebase/firestore/core/QueryListener.java index 9ab268e51eb..5c8155c38f8 100644 --- a/firebase-firestore/src/main/java/com/google/firebase/firestore/core/QueryListener.java +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/core/QueryListener.java @@ -53,7 +53,13 @@ public QueryListener( QueryOrPipeline query, EventManager.ListenOptions options, EventListener listener) { - this.query = query; + if (query.isPipeline()) { + this.query = + new QueryOrPipeline.PipelineWrapper( + query.pipeline().withListenOptions$com_google_firebase_firebase_firestore(options)); + } else { + this.query = query; + } this.listener = listener; this.options = options; } diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/local/LocalStore.java b/firebase-firestore/src/main/java/com/google/firebase/firestore/local/LocalStore.java index a214c61e353..8f5412ac10e 100644 --- a/firebase-firestore/src/main/java/com/google/firebase/firestore/local/LocalStore.java +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/local/LocalStore.java @@ -669,6 +669,9 @@ public TargetData allocateTarget(TargetOrPipeline target) { // This query has been listened to previously, so reuse the previous targetID. // TODO: freshen last accessed date? targetId = cached.getTargetId(); + // deserialized target is missing a firestore reference, so we use the one that has it + // to replace just to be safe. + cached = cached.withTarget(target); } else { final AllocateQueryHolder holder = new AllocateQueryHolder(); persistence.runTransaction( diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/local/TargetData.java b/firebase-firestore/src/main/java/com/google/firebase/firestore/local/TargetData.java index d43a5f6ab1f..b024aae5220 100644 --- a/firebase-firestore/src/main/java/com/google/firebase/firestore/local/TargetData.java +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/local/TargetData.java @@ -141,6 +141,18 @@ public TargetOrPipeline getTarget() { return target; } + TargetData withTarget(TargetOrPipeline target) { + return new TargetData( + target, + targetId, + sequenceNumber, + purpose, + snapshotVersion, + lastLimboFreeSnapshotVersion, + resumeToken, + expectedCount); + } + public int getTargetId() { return targetId; } diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/EvaluateResult.kt b/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/EvaluateResult.kt index c39946c07e2..12f83811882 100644 --- a/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/EvaluateResult.kt +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/EvaluateResult.kt @@ -45,6 +45,7 @@ internal sealed class EvaluateResult(val value: Value?) { } catch (e: IllegalArgumentException) { EvaluateResultError } + fun value(value: Value) = EvaluateResultValue(value) } } diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/evaluation.kt b/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/evaluation.kt index c70abfbadc9..6160cc57c99 100644 --- a/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/evaluation.kt +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/evaluation.kt @@ -16,10 +16,14 @@ package com.google.firebase.firestore.pipeline +import com.google.common.math.DoubleMath +import com.google.common.math.IntMath import com.google.common.math.LongMath import com.google.common.math.LongMath.checkedAdd import com.google.common.math.LongMath.checkedMultiply import com.google.common.math.LongMath.checkedSubtract +import com.google.common.primitives.Ints +import com.google.firebase.firestore.Blob import com.google.firebase.firestore.RealtimePipeline import com.google.firebase.firestore.model.MutableDocument import com.google.firebase.firestore.model.Values @@ -37,8 +41,13 @@ import com.google.re2j.PatternSyntaxException import java.math.BigDecimal import java.math.RoundingMode import kotlin.math.absoluteValue +import kotlin.math.exp import kotlin.math.floor +import kotlin.math.ln +import kotlin.math.log import kotlin.math.log10 +import kotlin.math.max +import kotlin.math.min import kotlin.math.pow import kotlin.math.sqrt @@ -66,6 +75,14 @@ internal val evaluateExists: EvaluateFunction = unaryFunction { r: EvaluateResul } } +internal val evaluateIsAbsent: EvaluateFunction = unaryFunction { r: EvaluateResult -> + when (r) { + EvaluateResultError -> r + EvaluateResultUnset -> EvaluateResult.TRUE + is EvaluateResultValue -> EvaluateResult.FALSE + } +} + internal val evaluateAnd: EvaluateFunction = { params -> fun(input: MutableDocument): EvaluateResult { var isError = false @@ -232,13 +249,33 @@ internal val evaluateMod = arithmeticPrimitive(Long::rem, Double::rem) internal val evaluateMultiply: EvaluateFunction = arithmeticPrimitive(LongMath::checkedMultiply, Double::times) -internal val evaluatePow: EvaluateFunction = arithmeticPrimitive(Math::pow) +internal val evaluatePow: EvaluateFunction = + arithmetic({ base: Double, exponent: Double -> + return@arithmetic if (exponent == 0.0 || base == 1.0) { + EvaluateResult.double(1.0) + } else if (base == -1.0 && exponent.isInfinite()) { + EvaluateResult.double(1.0) + } + + // Not referenced by GoogleSQL, but put here to be explicit. + else if (exponent.isNaN() || base.isNaN()) { + EvaluateResult.double(Double.NaN) + } + + // We can't have a non-integer exponent on a negative base because it may result in taking the + // undefined root of a negative number. + else if (base < 0 && base.isFinite() && !DoubleMath.isMathematicalInteger(exponent)) { + EvaluateResultError + } else if ((base == 0.0 || base == -0.0) && exponent < 0) { + EvaluateResultError + } else EvaluateResult.double(base.pow(exponent)) + }) internal val evaluateRound = arithmeticPrimitive( { it }, { input -> - if (input.isInfinite()) { + if (input.isFinite()) { val remainder = (input % 1) val truncated = input - remainder if (remainder.absoluteValue >= 0.5) truncated + (if (input < 0) -1 else 1) else truncated @@ -299,6 +336,35 @@ internal val evaluateRoundToPrecision = } ) +internal val evaluateAbs = + arithmeticPrimitive( + { l: Long -> + if (l == Long.MIN_VALUE) throw ArithmeticException("long overflow") + l.absoluteValue + }, + { d: Double -> d.absoluteValue } + ) + +internal val evaluateExp = arithmetic { value: Double -> EvaluateResult.double(exp(value)) } + +internal val evaluateLn = arithmetic { value: Double -> + if (value < 0) EvaluateResultError else EvaluateResult.double(ln(value)) +} + +internal val evaluateLog = arithmetic { value: Double, base: Double -> + return@arithmetic if (value == Double.NEGATIVE_INFINITY) { + EvaluateResultError + } else if (base == Double.POSITIVE_INFINITY) { + EvaluateResult.double(Double.NaN) + } else if (base <= 0 || value <= 0 || base == 1.0) { + EvaluateResultError + } else EvaluateResult.double(log(value, base)) +} + +internal val evaluateLog10 = arithmetic { value: Double -> + if (value < 0) EvaluateResultError else EvaluateResult.double(log10(value)) +} + internal val evaluateSqrt = arithmetic { value: Double -> if (value < 0) EvaluateResultError else EvaluateResult.double(sqrt(value)) } @@ -419,7 +485,75 @@ internal val evaluateReverse = unaryFunctionPrimitive(String::reversed) internal val evaluateSplit = notImplemented // TODO: Does not exist in expressions.kt yet. -internal val evaluateSubstring = notImplemented // TODO: Does not exist in expressions.kt yet. +private fun getIntegerOrElse(value: EvaluateResult): Long? { + if (!value.isSuccess) return null + if (value.value?.valueTypeCase != ValueTypeCase.INTEGER_VALUE) return null + return value.value?.integerValue +} + +internal val evaluateSubstring = ternaryLazyFunction { strFn, startFn, lengthFn -> + var start = getIntegerOrElse(startFn()) ?: return@ternaryLazyFunction EvaluateResultError + val length = getIntegerOrElse(lengthFn()) ?: return@ternaryLazyFunction EvaluateResultError + + if (length < 0) { + return@ternaryLazyFunction EvaluateResultError + } + + val str = strFn().value + when (str?.valueTypeCase) { + ValueTypeCase.STRING_VALUE -> { + val text = str.stringValue + // Rephrasing negative position to an equivalent positive value. + if (start < 0) { + start = max(0, text.codePointCount(0, text.length) + start) + } + + val codePointCount = text.codePointCount(0, text.length) + + if (start >= codePointCount) { + return@ternaryLazyFunction EvaluateResult.string("") + } + + val substring = StringBuilder() + var curIndex = text.offsetByCodePoints(0, min(start, Int.MAX_VALUE.toLong()).toInt()) + for (i in 0 until length) { + if (curIndex >= text.length) { + return@ternaryLazyFunction EvaluateResult.string(substring.toString()) + } + + substring.append(Character.toChars(text.codePointAt(curIndex))) + curIndex = text.offsetByCodePoints(curIndex, 1) + } + + return@ternaryLazyFunction EvaluateResult.string(substring.toString()) + } + ValueTypeCase.BYTES_VALUE -> { + val bytes = str.bytesValue + val bytesCount = bytes.size() - 1 + if (start < 0) { + // Adding 1 since position is inclusive. + start = max(0, bytesCount + start + 1) + } + + if (bytesCount < start) { + return@ternaryLazyFunction EvaluateResult.value(encodeValue(ByteArray(0))) + } + + val end = + min( + Int.MAX_VALUE, + min( + IntMath.saturatedAdd(Ints.saturatedCast(start), Ints.saturatedCast(length)), + bytesCount + 1 + ) + ) + return@ternaryLazyFunction EvaluateResult.value( + encodeValue(Blob.fromByteString(bytes.substring(start.toInt(), end.toInt()))) + ) + } + else -> return@ternaryLazyFunction EvaluateResultError + } +} internal val evaluateTrim = unaryFunctionPrimitive(String::trim) 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 c536d949171..14401daf748 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 @@ -17,6 +17,7 @@ package com.google.firebase.firestore.pipeline import com.google.firebase.Timestamp import com.google.firebase.firestore.Blob import com.google.firebase.firestore.DocumentReference +import com.google.firebase.firestore.DocumentSnapshot import com.google.firebase.firestore.FieldPath import com.google.firebase.firestore.GeoPoint import com.google.firebase.firestore.Pipeline @@ -29,6 +30,9 @@ import com.google.firebase.firestore.model.FieldPath.CREATE_TIME_PATH import com.google.firebase.firestore.model.FieldPath.KEY_PATH import com.google.firebase.firestore.model.FieldPath.UPDATE_TIME_PATH import com.google.firebase.firestore.model.MutableDocument +import com.google.firebase.firestore.model.ServerTimestamps.getLocalWriteTime +import com.google.firebase.firestore.model.ServerTimestamps.getPreviousValue +import com.google.firebase.firestore.model.ServerTimestamps.isServerTimestamp import com.google.firebase.firestore.model.Values import com.google.firebase.firestore.model.Values.canonicalId import com.google.firebase.firestore.model.Values.encodeValue @@ -303,6 +307,13 @@ abstract class Expr internal constructor() : Canonicalizable { */ @JvmStatic fun field(fieldPath: FieldPath): Field = Field(fieldPath.internalPath) + /** + * Creates a generic function expression that is not yet implemented. + * + * @param name The name of the generic function. + * @param expr The expressions to be passed as arguments to the function. + * @return A new [Expr] representing the generic function. + */ @JvmStatic fun generic(name: String, vararg expr: Expr): Expr = FunctionExpr(name, notImplemented, expr) @@ -757,6 +768,120 @@ abstract class Expr internal constructor() : Canonicalizable { fun pow(numericField: String, exponent: Expr): Expr = FunctionExpr("pow", evaluatePow, numericField, exponent) + /** + * Creates an expression that returns the absolute value of [numericExpr]. + * + * @param numericExpr An expression that returns number when evaluated. + * @return A new [Expr] representing the numeric result of the absolute value operation. + */ + @JvmStatic fun abs(numericExpr: Expr): Expr = FunctionExpr("abs", evaluateAbs, numericExpr) + + /** + * Creates an expression that returns the absolute value of [numericField]. + * + * @param numericField Name of field that returns number when evaluated. + * @return A new [Expr] representing the numeric result of the absolute value operation. + */ + @JvmStatic fun abs(numericField: String): Expr = FunctionExpr("abs", evaluateAbs, numericField) + + /** + * Creates an expression that returns Euler's number e raised to the power of [numericExpr]. + * + * @param numericExpr An expression that returns number when evaluated. + * @return A new [Expr] representing the numeric result of the exponentiation. + */ + @JvmStatic fun exp(numericExpr: Expr): Expr = FunctionExpr("exp", evaluateExp, numericExpr) + + /** + * Creates an expression that returns Euler's number e raised to the power of [numericField]. + * + * @param numericField Name of field that returns number when evaluated. + * @return A new [Expr] representing the numeric result of the exponentiation. + */ + @JvmStatic fun exp(numericField: String): Expr = FunctionExpr("exp", evaluateExp, numericField) + + /** + * Creates an expression that returns the natural logarithm (base e) of [numericExpr]. + * + * @param numericExpr An expression that returns number when evaluated. + * @return A new [Expr] representing the numeric result of the natural logarithm. + */ + @JvmStatic fun ln(numericExpr: Expr): Expr = FunctionExpr("ln", evaluateLn, numericExpr) + + /** + * Creates an expression that returns the natural logarithm (base e) of [numericField]. + * + * @param numericField Name of field that returns number when evaluated. + * @return A new [Expr] representing the numeric result of the natural logarithm. + */ + @JvmStatic fun ln(numericField: String): Expr = FunctionExpr("ln", evaluateLn, numericField) + + /** + * Creates an expression that returns the logarithm of [numericExpr] with a given [base]. + * + * @param numericExpr An expression that returns number when evaluated. + * @param base The base of the logarithm. + * @return A new [Expr] representing a numeric result from the logarithm of [numericExpr] with a + * given [base]. + */ + @JvmStatic + fun log(numericExpr: Expr, base: Number): Expr = + FunctionExpr("log", evaluateLog, numericExpr, constant(base)) + + /** + * Creates an expression that returns the logarithm of [numericField] with a given [base]. + * + * @param numericField Name of field that returns number when evaluated. + * @param base The base of the logarithm. + * @return A new [Expr] representing a numeric result from the logarithm of [numericField] with + * a given [base]. + */ + @JvmStatic + fun log(numericField: String, base: Number): Expr = + FunctionExpr("log", evaluateLog, numericField, constant(base)) + + /** + * Creates an expression that returns the logarithm of [numericExpr] with a given [base]. + * + * @param numericExpr An expression that returns number when evaluated. + * @param base The base of the logarithm. + * @return A new [Expr] representing a numeric result from the logarithm of [numericExpr] with a + * given [base]. + */ + @JvmStatic + fun log(numericExpr: Expr, base: Expr): Expr = + FunctionExpr("log", evaluateLog, numericExpr, base) + + /** + * Creates an expression that returns the logarithm of [numericField] with a given [base]. + * + * @param numericField Name of field that returns number when evaluated. + * @param base The base of the logarithm. + * @return A new [Expr] representing a numeric result from the logarithm of [numericField] with + * a given [base]. + */ + @JvmStatic + fun log(numericField: String, base: Expr): Expr = + FunctionExpr("log", evaluateLog, numericField, base) + + /** + * Creates an expression that returns the base 10 logarithm of [numericExpr]. + * + * @param numericExpr An expression that returns number when evaluated. + * @return A new [Expr] representing the numeric result of the base 10 logarithm. + */ + @JvmStatic + fun log10(numericExpr: Expr): Expr = FunctionExpr("log10", evaluateLog10, numericExpr) + + /** + * Creates an expression that returns the base 10 logarithm of [numericField]. + * + * @param numericField Name of field that returns number when evaluated. + * @return A new [Expr] representing the numeric result of the base 10 logarithm. + */ + @JvmStatic + fun log10(numericField: String): Expr = FunctionExpr("log10", evaluateLog10, numericField) + /** * Creates an expression that returns the square root of [numericExpr]. * @@ -1102,7 +1227,7 @@ abstract class Expr internal constructor() : Canonicalizable { * @return A new [BooleanExpr] representing the isAbsent operation. */ @JvmStatic - fun isAbsent(value: Expr): BooleanExpr = BooleanExpr("is_absent", notImplemented, value) + fun isAbsent(value: Expr): BooleanExpr = BooleanExpr("is_absent", evaluateIsAbsent, value) /** * Creates an expression that returns true if a field is absent. Otherwise, returns false even @@ -1113,7 +1238,7 @@ abstract class Expr internal constructor() : Canonicalizable { */ @JvmStatic fun isAbsent(fieldName: String): BooleanExpr = - BooleanExpr("is_absent", notImplemented, fieldName) + BooleanExpr("is_absent", evaluateIsAbsent, fieldName) /** * Creates an expression that checks if an expression evaluates to 'NaN' (Not a Number). @@ -1682,6 +1807,30 @@ abstract class Expr internal constructor() : Canonicalizable { fun endsWith(fieldName: String, suffix: String): BooleanExpr = BooleanExpr("ends_with", evaluateEndsWith, fieldName, suffix) + /** + * Creates an expression that returns a substring of the given string. + * + * @param stringExpression The expression representing the string to get a substring from. + * @param index The starting index of the substring. + * @param length The length of the substring. + * @return A new [Expr] representing the substring. + */ + @JvmStatic + fun substr(stringExpression: Expr, index: Expr, length: Expr): Expr = + FunctionExpr("substr", evaluateSubstring, stringExpression, index, length) + + /** + * Creates an expression that returns a substring of the given string. + * + * @param fieldName The name of the field containing the string to get a substring from. + * @param index The starting index of the substring. + * @param length The length of the substring. + * @return A new [Expr] representing the substring. + */ + @JvmStatic + fun substr(fieldName: String, index: Int, length: Int): Expr = + FunctionExpr("substr", evaluateSubstring, fieldName, index, length) + /** * Creates an expression that converts a string expression to lowercase. * @@ -1690,7 +1839,7 @@ abstract class Expr internal constructor() : Canonicalizable { */ @JvmStatic fun toLower(stringExpression: Expr): Expr = - FunctionExpr("to_lowercase", evaluateToLowercase, stringExpression) + FunctionExpr("to_lower", evaluateToLowercase, stringExpression) /** * Creates an expression that converts a string field to lowercase. @@ -1699,8 +1848,7 @@ abstract class Expr internal constructor() : Canonicalizable { * @return A new [Expr] representing the lowercase string. */ @JvmStatic - fun toLower(fieldName: String): Expr = - FunctionExpr("to_lowercase", evaluateToLowercase, fieldName) + fun toLower(fieldName: String): Expr = FunctionExpr("to_lower", evaluateToLowercase, fieldName) /** * Creates an expression that converts a string expression to uppercase. @@ -1710,7 +1858,7 @@ abstract class Expr internal constructor() : Canonicalizable { */ @JvmStatic fun toUpper(stringExpression: Expr): Expr = - FunctionExpr("to_uppercase", evaluateToUppercase, stringExpression) + FunctionExpr("to_upper", evaluateToUppercase, stringExpression) /** * Creates an expression that converts a string field to uppercase. @@ -1719,8 +1867,7 @@ abstract class Expr internal constructor() : Canonicalizable { * @return A new [Expr] representing the lowercase string. */ @JvmStatic - fun toUpper(fieldName: String): Expr = - FunctionExpr("to_uppercase", evaluateToUppercase, fieldName) + fun toUpper(fieldName: String): Expr = FunctionExpr("to_upper", evaluateToUppercase, fieldName) /** * Creates an expression that removes leading and trailing whitespace from a string expression. @@ -2284,7 +2431,7 @@ abstract class Expr internal constructor() : Canonicalizable { * @return A new [Expr] representing the resulting timestamp. */ @JvmStatic - fun timestampAdd(timestamp: Expr, unit: String, amount: Double): Expr = + fun timestampAdd(timestamp: Expr, unit: String, amount: Long): Expr = FunctionExpr("timestamp_add", evaluateTimestampAdd, timestamp, unit, amount) /** @@ -2310,7 +2457,7 @@ abstract class Expr internal constructor() : Canonicalizable { * @return A new [Expr] representing the resulting timestamp. */ @JvmStatic - fun timestampAdd(fieldName: String, unit: String, amount: Double): Expr = + fun timestampAdd(fieldName: String, unit: String, amount: Long): Expr = FunctionExpr("timestamp_add", evaluateTimestampAdd, fieldName, unit, amount) /** @@ -2336,7 +2483,7 @@ abstract class Expr internal constructor() : Canonicalizable { * @return A new [Expr] representing the resulting timestamp. */ @JvmStatic - fun timestampSub(timestamp: Expr, unit: String, amount: Double): Expr = + fun timestampSub(timestamp: Expr, unit: String, amount: Long): Expr = FunctionExpr("timestamp_sub", evaluateTimestampSub, timestamp, unit, amount) /** @@ -2362,7 +2509,7 @@ abstract class Expr internal constructor() : Canonicalizable { * @return A new [Expr] representing the resulting timestamp. */ @JvmStatic - fun timestampSub(fieldName: String, unit: String, amount: Double): Expr = + fun timestampSub(fieldName: String, unit: String, amount: Long): Expr = FunctionExpr("timestamp_sub", evaluateTimestampSub, fieldName, unit, amount) /** @@ -3126,7 +3273,7 @@ abstract class Expr internal constructor() : Canonicalizable { * @return A new [Selectable] (typically an [ExprWithAlias]) that wraps this expression and * associates it with the provided alias. */ - open fun alias(alias: String) = ExprWithAlias(alias, this) + open fun alias(alias: String): Selectable = ExprWithAlias(alias, this) /** * Creates an expression that returns the document ID from this path expression. @@ -3562,6 +3709,25 @@ abstract class Expr internal constructor() : Canonicalizable { */ fun endsWith(suffix: String) = Companion.endsWith(this, suffix) + /** + * Creates an expression that returns a substring of the given string. + * + * @param start The starting index of the substring. + * @param length The length of the substring. + * @return A new [Expr] representing the substring. + */ + fun substr(start: Expr, length: Expr): Expr = Companion.substr(this, start, length) + + /** + * Creates an expression that returns a substring of the given string. + * + * @param start The starting index of the substring. + * @param length The length of the substring. + * @return A new [Expr] representing the substring. + */ + fun substr(start: Int, length: Int): Expr = + Companion.substr(this, constant(start), constant(length)) + /** * Creates an expression that converts this string expression to lowercase. * @@ -3796,7 +3962,7 @@ abstract class Expr internal constructor() : Canonicalizable { * @param amount The amount of time to add. * @return A new [Expr] representing the resulting timestamp. */ - fun timestampAdd(unit: String, amount: Double): Expr = Companion.timestampAdd(this, unit, amount) + fun timestampAdd(unit: String, amount: Long): Expr = Companion.timestampAdd(this, unit, amount) /** * Creates an expression that subtracts a specified amount of time to this timestamp expression. @@ -3816,7 +3982,7 @@ abstract class Expr internal constructor() : Canonicalizable { * @param amount The amount of time to subtract. * @return A new [Expr] representing the resulting timestamp. */ - fun timestampSub(unit: String, amount: Double): Expr = Companion.timestampSub(this, unit, amount) + fun timestampSub(unit: String, amount: Long): Expr = Companion.timestampSub(this, unit, amount) /** * Creates an expression that concatenates a field's array value with other arrays. @@ -4184,17 +4350,40 @@ class Field internal constructor(internal val fieldPath: ModelFieldPath) : Selec internal fun toProto(): Value = Value.newBuilder().setFieldReferenceValue(fieldPath.canonicalString()).build() - override fun evaluateFunction(context: EvaluationContext) = - block@{ input: MutableDocument -> - EvaluateResultValue( - when (fieldPath) { - KEY_PATH -> Value.newBuilder().setReferenceValue(input.key.path.canonicalString()).build() - CREATE_TIME_PATH -> encodeValue(input.createTime.timestamp) - UPDATE_TIME_PATH -> encodeValue(input.version.timestamp) - else -> input.getField(fieldPath) ?: return@block EvaluateResultUnset + override fun evaluateFunction(context: EvaluationContext) = { input: MutableDocument -> + when (fieldPath) { + KEY_PATH -> + EvaluateResultValue( + Value.newBuilder().setReferenceValue(input.key.path.canonicalString()).build() + ) + CREATE_TIME_PATH -> EvaluateResultValue(encodeValue(input.createTime.timestamp)) + UPDATE_TIME_PATH -> EvaluateResultValue(encodeValue(input.version.timestamp)) + else -> + input.getField(fieldPath)?.let { fieldValue -> + // This block runs only if fieldValue is not null. + if (isServerTimestamp(fieldValue)) { + getServerTimestamp(fieldValue, context) + } else { + EvaluateResultValue(fieldValue) + } } - ) + ?: EvaluateResultUnset // This value is used if getField() returns null. } + } + private fun getServerTimestamp(fieldValue: Value, context: EvaluationContext): EvaluateResult { + val behavior = + context.pipeline.internalOptions?.serverTimestampBehavior + ?: DocumentSnapshot.ServerTimestampBehavior.NONE + return when (behavior) { + DocumentSnapshot.ServerTimestampBehavior.NONE -> EvaluateResult.NULL + DocumentSnapshot.ServerTimestampBehavior.ESTIMATE -> + EvaluateResult.timestamp(getLocalWriteTime(fieldValue)) + DocumentSnapshot.ServerTimestampBehavior.PREVIOUS -> { + val previousValue = getPreviousValue(fieldValue) + if (previousValue == null) EvaluateResult.NULL else EvaluateResultValue(previousValue!!) + } + } + } override fun canonicalId(): String = "fld(${fieldPath.canonicalString()})" @@ -4346,6 +4535,11 @@ internal constructor(name: String, function: EvaluateFunction, params: Array = - sequenceOf(Value.newBuilder().setReferenceValue(path.canonicalString()).build()) + sequenceOf(Value.newBuilder().setReferenceValue("/${path.canonicalString()}").build()) companion object { /** * Set the pipeline's source to the collection specified by the given CollectionReference. @@ -436,7 +436,7 @@ internal constructor( * @return [AggregateStage] with specified groups. */ fun withGroups(groupField: String, vararg additionalGroups: Any) = - withGroups(Expr.field(groupField), additionalGroups) + withGroups(Expr.field(groupField), *additionalGroups) /** * Add one or more groups to [AggregateStage] diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/remote/RemoteSerializer.java b/firebase-firestore/src/main/java/com/google/firebase/firestore/remote/RemoteSerializer.java index 1329663791b..ea4ecf8b80c 100644 --- a/firebase-firestore/src/main/java/com/google/firebase/firestore/remote/RemoteSerializer.java +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/remote/RemoteSerializer.java @@ -675,7 +675,10 @@ public RealtimePipeline decodePipelineQueryTarget(PipelineQueryTarget proto) { decodedStages.add(decodeStage(stageProto)); } - return new RealtimePipeline(this, new UserDataReader(this.databaseId()), decodedStages); + // It is ok for firestore field to be null, because deserialzed realtime pipeline is only used + // for facilitating pipeline request in remote store, firestore field is not used. + return new RealtimePipeline( + null, this, new UserDataReader(this.databaseId()), decodedStages, null); } private Stage decodeStage(com.google.firestore.v1.Pipeline.Stage protoStage) { diff --git a/firebase-firestore/src/test/java/com/google/firebase/firestore/DocumentChangeTest.java b/firebase-firestore/src/test/java/com/google/firebase/firestore/DocumentChangeTest.java index d8d5eff691f..4215f77d0f8 100644 --- a/firebase-firestore/src/test/java/com/google/firebase/firestore/DocumentChangeTest.java +++ b/firebase-firestore/src/test/java/com/google/firebase/firestore/DocumentChangeTest.java @@ -14,6 +14,7 @@ package com.google.firebase.firestore; +import static com.google.firebase.firestore.RealtimePipelineKt.changesFromSnapshot; import static com.google.firebase.firestore.model.DocumentCollections.emptyDocumentMap; import static com.google.firebase.firestore.testutil.TestUtil.ackTarget; import static com.google.firebase.firestore.testutil.TestUtil.deletedDoc; @@ -98,7 +99,18 @@ private static void validatePositions( FirebaseFirestore firestore = mock(FirebaseFirestore.class); List changes = - DocumentChange.changesFromSnapshot(firestore, MetadataChanges.EXCLUDE, updatedSnapshot); + changesFromSnapshot( + MetadataChanges.EXCLUDE, + updatedSnapshot, + (doc, type, oldIndex, newIndex) -> { + QueryDocumentSnapshot documentSnapshot = + QueryDocumentSnapshot.fromDocument( + firestore, + doc, + updatedSnapshot.isFromCache(), + updatedSnapshot.getMutatedKeys().contains(doc.getKey())); + return new DocumentChange(documentSnapshot, type, oldIndex, newIndex); + }); for (DocumentChange change : changes) { if (change.getType() != Type.ADDED) { diff --git a/firebase-firestore/src/test/java/com/google/firebase/firestore/local/QueryEngineTestCase.java b/firebase-firestore/src/test/java/com/google/firebase/firestore/local/QueryEngineTestCase.java index fe7250191ad..4d9b8a76ae7 100644 --- a/firebase-firestore/src/test/java/com/google/firebase/firestore/local/QueryEngineTestCase.java +++ b/firebase-firestore/src/test/java/com/google/firebase/firestore/local/QueryEngineTestCase.java @@ -521,9 +521,12 @@ public void canPerformOrQueriesUsingFullCollectionScan() throws Exception { expectFullCollectionScan(() -> runQuery(query6, MISSING_LAST_LIMBO_FREE_SNAPSHOT)); assertEquals(docSet(query6.comparator(), doc1, doc2), result6); - // Test with limits (implicit order by DESC): (a==1) || (b > 0) LIMIT_TO_LAST 2 + // Test with limits (order by b ASC): (a==1) || (b > 0) LIMIT_TO_LAST 2 Query query7 = - query("coll").filter(orFilters(filter("a", "==", 1), filter("b", ">", 0))).limitToLast(2); + query("coll") + .filter(orFilters(filter("a", "==", 1), filter("b", ">", 0))) + .orderBy(orderBy("b", "asc")) + .limitToLast(2); DocumentSet result7 = expectFullCollectionScan(() -> runQuery(query7, MISSING_LAST_LIMBO_FREE_SNAPSHOT)); assertEquals(docSet(query7.comparator(), doc3, doc4), result7); diff --git a/firebase-firestore/src/test/java/com/google/firebase/firestore/pipeline/ArithmeticTests.kt b/firebase-firestore/src/test/java/com/google/firebase/firestore/pipeline/ArithmeticTests.kt index 31ca2d811a3..ac5e65595d9 100644 --- a/firebase-firestore/src/test/java/com/google/firebase/firestore/pipeline/ArithmeticTests.kt +++ b/firebase-firestore/src/test/java/com/google/firebase/firestore/pipeline/ArithmeticTests.kt @@ -16,12 +16,23 @@ package com.google.firebase.firestore.pipeline import com.google.common.truth.Truth.assertThat import com.google.firebase.firestore.model.Values.encodeValue // Returns com.google.protobuf.Value +import com.google.firebase.firestore.pipeline.Expr.Companion.abs import com.google.firebase.firestore.pipeline.Expr.Companion.add +import com.google.firebase.firestore.pipeline.Expr.Companion.ceil import com.google.firebase.firestore.pipeline.Expr.Companion.constant import com.google.firebase.firestore.pipeline.Expr.Companion.divide +import com.google.firebase.firestore.pipeline.Expr.Companion.exp +import com.google.firebase.firestore.pipeline.Expr.Companion.floor +import com.google.firebase.firestore.pipeline.Expr.Companion.ln +import com.google.firebase.firestore.pipeline.Expr.Companion.log +import com.google.firebase.firestore.pipeline.Expr.Companion.log10 import com.google.firebase.firestore.pipeline.Expr.Companion.mod import com.google.firebase.firestore.pipeline.Expr.Companion.multiply +import com.google.firebase.firestore.pipeline.Expr.Companion.pow +import com.google.firebase.firestore.pipeline.Expr.Companion.round +import com.google.firebase.firestore.pipeline.Expr.Companion.sqrt import com.google.firebase.firestore.pipeline.Expr.Companion.subtract +import kotlin.math.E import org.junit.Test internal class ArithmeticTests { @@ -546,4 +557,783 @@ internal class ArithmeticTests { ) .isEqualTo(encodeValue(Double.NaN)) } + + // --- Abs Tests --- + @Test + fun absFunctionTestWithLong() { + assertThat(evaluate(abs(constant(-42L))).value).isEqualTo(encodeValue(42L)) + assertThat(evaluate(abs(constant(42L))).value).isEqualTo(encodeValue(42L)) + } + + @Test + fun absFunctionTestWithLongZero() { + assertThat(evaluate(abs(constant(0L))).value).isEqualTo(encodeValue(0L)) + assertThat(evaluate(abs(constant(-0L))).value).isEqualTo(encodeValue(0L)) + } + + @Test + fun absFunctionTestWithLongMinValue() { + assertThat(evaluate(abs(constant(Long.MIN_VALUE))).isError).isTrue() + } + + @Test + fun absFunctionTestWithLongMaxValue() { + assertThat(evaluate(abs(constant(Long.MAX_VALUE))).value).isEqualTo(encodeValue(Long.MAX_VALUE)) + assertThat(evaluate(abs(constant(-Long.MAX_VALUE))).value) + .isEqualTo(encodeValue(Long.MAX_VALUE)) + } + + @Test + fun absFunctionTestWithDouble() { + assertThat(evaluate(abs(constant(-42.1))).value).isEqualTo(encodeValue(42.1)) + assertThat(evaluate(abs(constant(42.1))).value).isEqualTo(encodeValue(42.1)) + } + + @Test + fun absFunctionTestWithDoubleZero() { + assertThat(evaluate(abs(constant(-0.0))).value).isEqualTo(encodeValue(0.0)) + assertThat(evaluate(abs(constant(0.0))).value).isEqualTo(encodeValue(0.0)) + } + + @Test + fun absFunctionTestWithDoubleMinMaxValue() { + assertThat(evaluate(abs(constant(Double.MAX_VALUE))).value) + .isEqualTo(encodeValue(Double.MAX_VALUE)) + assertThat(evaluate(abs(constant(-Double.MAX_VALUE))).value) + .isEqualTo(encodeValue(Double.MAX_VALUE)) + assertThat(evaluate(abs(constant(Double.MIN_VALUE))).value) + .isEqualTo(encodeValue(Double.MIN_VALUE)) + assertThat(evaluate(abs(constant(-Double.MIN_VALUE))).value) + .isEqualTo(encodeValue(Double.MIN_VALUE)) + } + + @Test + fun absFunctionTestWithInfinity() { + assertThat(evaluate(abs(constant(Double.POSITIVE_INFINITY))).value) + .isEqualTo(encodeValue(Double.POSITIVE_INFINITY)) + assertThat(evaluate(abs(constant(Double.NEGATIVE_INFINITY))).value) + .isEqualTo(encodeValue(Double.POSITIVE_INFINITY)) + } + + @Test + fun absFunctionTestWithNaN() { + assertThat(evaluate(abs(constant(Double.NaN))).value).isEqualTo(encodeValue(Double.NaN)) + } + + @Test + fun absFunctionTestWithNonNumeric() { + assertThat(evaluate(abs(constant("1"))).isError).isTrue() + } + + // --- Exp Tests --- + @Test + fun expFunctionTestWithDouble() { + assertThat(evaluate(exp(constant(2.0))).value).isEqualTo(encodeValue(kotlin.math.exp(2.0))) + } + + @Test + fun expFunctionTestWithInteger() { + assertThat(evaluate(exp(constant(2))).value).isEqualTo(encodeValue(kotlin.math.exp(2.0))) + } + + @Test + fun expFunctionTestWithLong() { + assertThat(evaluate(exp(constant(2L))).value).isEqualTo(encodeValue(kotlin.math.exp(2.0))) + } + + @Test + fun expFunctionTestWithZero() { + assertThat(evaluate(exp(constant(0))).value).isEqualTo(encodeValue(1.0)) + } + + @Test + fun expFunctionTestWithNegativeZero() { + assertThat(evaluate(exp(constant(-0.0))).value).isEqualTo(encodeValue(1.0)) + } + + @Test + fun expFunctionTestWithNegative() { + assertThat(evaluate(exp(constant(-1))).value).isEqualTo(encodeValue(1 / E)) + } + + @Test + fun expFunctionTestWithInfinity() { + assertThat(evaluate(exp(constant(Double.POSITIVE_INFINITY))).value) + .isEqualTo(encodeValue(Double.POSITIVE_INFINITY)) + } + + @Test + fun expFunctionTestWithNegativeInfinity() { + assertThat(evaluate(exp(constant(Double.NEGATIVE_INFINITY))).value).isEqualTo(encodeValue(0.0)) + } + + @Test + fun expFunctionTestWithNaN() { + assertThat(evaluate(exp(constant(Double.NaN))).value).isEqualTo(encodeValue(Double.NaN)) + } + + @Test + fun expFunctionTestWithNegativeConstant() { + assertThat(evaluate(exp(constant(-16.0))).value).isEqualTo(encodeValue(kotlin.math.exp(-16.0))) + } + + @Test + fun expFunctionTestWithDoubleOverflow() { + assertThat(evaluate(exp(constant(Double.MAX_VALUE))).value) + .isEqualTo(encodeValue(Double.POSITIVE_INFINITY)) + } + + @Test + fun expFunctionTestWithUnsupportedType() { + assertThat(evaluate(exp(constant("foo"))).isError).isTrue() + } + + // --- Ln Tests --- + @Test + fun lnFunctionTestWithDouble() { + assertThat(evaluate(ln(constant(kotlin.math.exp(16.0)))).value).isEqualTo(encodeValue(16.0)) + } + + @Test + fun lnFunctionTestWithInteger() { + assertThat(evaluate(ln(constant(1))).value).isEqualTo(encodeValue(0.0)) + } + + @Test + fun lnFunctionTestWithLong() { + assertThat(evaluate(ln(constant(1L))).value).isEqualTo(encodeValue(0.0)) + } + + @Test + fun lnFunctionTestWithZero() { + assertThat(evaluate(ln(constant(0))).value).isEqualTo(encodeValue(Double.NEGATIVE_INFINITY)) + } + + @Test + fun lnFunctionTestWithNegativeZero() { + assertThat(evaluate(ln(constant(-0.0))).value).isEqualTo(encodeValue(Double.NEGATIVE_INFINITY)) + } + + @Test + fun lnFunctionTestWithNegative() { + assertThat(evaluate(ln(constant(-1))).isError).isTrue() + } + + @Test + fun lnFunctionTestWithInfinity() { + assertThat(evaluate(ln(constant(Double.POSITIVE_INFINITY))).value) + .isEqualTo(encodeValue(Double.POSITIVE_INFINITY)) + } + + @Test + fun lnFunctionTestWithNegativeInfinity() { + assertThat(evaluate(ln(constant(Double.NEGATIVE_INFINITY))).isError).isTrue() + } + + @Test + fun lnFunctionTestWithNegativeConstant() { + assertThat(evaluate(ln(constant(-16.0))).isError).isTrue() + } + + @Test + fun lnFunctionTestWithUnsupportedType() { + assertThat(evaluate(ln(constant("foo"))).isError).isTrue() + } + + // --- Log Tests --- + @Test + fun logFunctionTest() { + assertThat(evaluate(log(constant(100.0), constant(10.0))).value).isEqualTo(encodeValue(2.0)) + assertThat(evaluate(log(constant(100), constant(10))).value).isEqualTo(encodeValue(2.0)) + assertThat(evaluate(log(constant(100L), constant(10L))).value).isEqualTo(encodeValue(2.0)) + assertThat(evaluate(log(constant(100.0), constant(0.0))).isError).isTrue() + assertThat(evaluate(log(constant(100.0), constant(-10.0))).isError).isTrue() + assertThat(evaluate(log(constant(100.0), constant(1.0))).isError).isTrue() + assertThat(evaluate(log(constant(0.0), constant(10.0))).isError).isTrue() + assertThat(evaluate(log(constant(100), constant(1.0))).isError).isTrue() + assertThat(evaluate(log(constant(-100.0), constant(10.0))).isError).isTrue() + assertThat(evaluate(log(constant("foo"), constant(10.0))).isError).isTrue() + assertThat(evaluate(log(constant(100.0), constant("bar"))).isError).isTrue() + } + + @Test + fun logFunctionTestWithInfiniteSemantics() { + assertThat(evaluate(log(constant(Double.NEGATIVE_INFINITY), constant(0.0))).isError).isTrue() + assertThat( + evaluate(log(constant(Double.NEGATIVE_INFINITY), constant(Double.NEGATIVE_INFINITY))) + .isError + ) + .isTrue() + assertThat( + evaluate(log(constant(Double.NEGATIVE_INFINITY), constant(Double.POSITIVE_INFINITY))) + .isError + ) + .isTrue() + assertThat(evaluate(log(constant(Double.NEGATIVE_INFINITY), constant(10.0))).isError).isTrue() + assertThat(evaluate(log(constant(0.0), constant(Double.POSITIVE_INFINITY))).value) + .isEqualTo(encodeValue(Double.NaN)) + assertThat(evaluate(log(constant(-10.0), constant(Double.POSITIVE_INFINITY))).value) + .isEqualTo(encodeValue(Double.NaN)) + assertThat(evaluate(log(constant(10.0), constant(Double.POSITIVE_INFINITY))).value) + .isEqualTo(encodeValue(Double.NaN)) + assertThat( + evaluate(log(constant(Double.POSITIVE_INFINITY), constant(Double.POSITIVE_INFINITY))).value + ) + .isEqualTo(encodeValue(Double.NaN)) + assertThat(evaluate(log(constant(Double.POSITIVE_INFINITY), constant(0.01))).value) + .isEqualTo(encodeValue(Double.NEGATIVE_INFINITY)) + assertThat(evaluate(log(constant(Double.POSITIVE_INFINITY), constant(0.99))).value) + .isEqualTo(encodeValue(Double.NEGATIVE_INFINITY)) + assertThat(evaluate(log(constant(Double.POSITIVE_INFINITY), constant(1.1))).value) + .isEqualTo(encodeValue(Double.POSITIVE_INFINITY)) + assertThat(evaluate(log(constant(Double.POSITIVE_INFINITY), constant(10.0))).value) + .isEqualTo(encodeValue(Double.POSITIVE_INFINITY)) + } + + // --- Log10 Tests --- + @Test + fun log10FunctionTestWithDouble() { + assertThat(evaluate(log10(constant(100.0))).value).isEqualTo(encodeValue(2.0)) + } + + @Test + fun log10FunctionTestWithInteger() { + assertThat(evaluate(log10(constant(100))).value).isEqualTo(encodeValue(2.0)) + } + + @Test + fun log10FunctionTestWithLong() { + assertThat(evaluate(log10(constant(100L))).value).isEqualTo(encodeValue(2.0)) + } + + @Test + fun log10FunctionTestWithZero() { + assertThat(evaluate(log10(constant(0))).value).isEqualTo(encodeValue(Double.NEGATIVE_INFINITY)) + } + + @Test + fun log10FunctionTestWithNegativeZero() { + assertThat(evaluate(log10(constant(-0.0))).value) + .isEqualTo(encodeValue(Double.NEGATIVE_INFINITY)) + } + + @Test + fun log10FunctionTestWithNegative() { + assertThat(evaluate(log10(constant(-1))).isError).isTrue() + } + + @Test + fun log10FunctionTestWithInfinity() { + assertThat(evaluate(log10(constant(Double.POSITIVE_INFINITY))).value) + .isEqualTo(encodeValue(Double.POSITIVE_INFINITY)) + } + + @Test + fun log10FunctionTestWithNegativeInfinity() { + assertThat(evaluate(log10(constant(Double.NEGATIVE_INFINITY))).isError).isTrue() + } + + @Test + fun log10FunctionTestWithNegativeConstant() { + assertThat(evaluate(log10(constant(-16.0))).isError).isTrue() + } + + @Test + fun log10FunctionTestWithUnsupportedType() { + assertThat(evaluate(log10(constant("foo"))).isError).isTrue() + } + + // --- Ceil Tests --- + @Test + fun ceilFunctionTestWithInteger() { + assertThat(evaluate(ceil(constant(15))).value).isEqualTo(encodeValue(15L)) + } + + @Test + fun ceilFunctionTestWithNegativeInteger() { + assertThat(evaluate(ceil(constant(-1))).value).isEqualTo(encodeValue(-1L)) + } + + @Test + fun ceilFunctionTestWithLong() { + assertThat(evaluate(ceil(constant(15L))).value).isEqualTo(encodeValue(15L)) + } + + @Test + fun ceilFunctionTestWithNegativeLong() { + assertThat(evaluate(ceil(constant(-1L))).value).isEqualTo(encodeValue(-1L)) + } + + @Test + fun ceilFunctionTestWithDouble() { + assertThat(evaluate(ceil(constant(15.1))).value).isEqualTo(encodeValue(16.0)) + } + + @Test + fun ceilFunctionTestWithNegativeDouble() { + assertThat(evaluate(ceil(constant(-1.1))).value).isEqualTo(encodeValue(-1.0)) + } + + @Test + fun ceilFunctionTestWithDoubleWholeNumber() { + assertThat(evaluate(ceil(constant(15.0))).value).isEqualTo(encodeValue(15.0)) + } + + @Test + fun ceilFunctionTestWithInvalidType() { + assertThat(evaluate(ceil(constant("invalid"))).isError).isTrue() + } + + @Test + fun ceilFunctionTestWithPositiveInfinity() { + assertThat(evaluate(ceil(constant(Double.POSITIVE_INFINITY))).value) + .isEqualTo(encodeValue(Double.POSITIVE_INFINITY)) + } + + @Test + fun ceilFunctionTestWithNegativeInfinity() { + assertThat(evaluate(ceil(constant(Double.NEGATIVE_INFINITY))).value) + .isEqualTo(encodeValue(Double.NEGATIVE_INFINITY)) + } + + @Test + fun ceilFunctionTestWithNaN() { + assertThat(evaluate(ceil(constant(Double.NaN))).value).isEqualTo(encodeValue(Double.NaN)) + } + + @Test + fun ceilFunctionToNegativeZero() { + assertThat(evaluate(ceil(constant(-0.4))).value).isEqualTo(encodeValue(-0.0)) + } + + // --- Floor Tests --- + @Test + fun floorFunctionTestWithInteger() { + assertThat(evaluate(floor(constant(Integer.MIN_VALUE))).value) + .isEqualTo(encodeValue(Integer.MIN_VALUE.toLong())) + assertThat(evaluate(floor(constant(-15))).value).isEqualTo(encodeValue(-15L)) + assertThat(evaluate(floor(constant(0))).value).isEqualTo(encodeValue(0L)) + assertThat(evaluate(floor(constant(15))).value).isEqualTo(encodeValue(15L)) + assertThat(evaluate(floor(constant(Integer.MAX_VALUE))).value) + .isEqualTo(encodeValue(Integer.MAX_VALUE.toLong())) + } + + @Test + fun floorFunctionTestWithLong() { + assertThat(evaluate(floor(constant(Long.MIN_VALUE))).value) + .isEqualTo(encodeValue(Long.MIN_VALUE)) + assertThat(evaluate(floor(constant(-15L))).value).isEqualTo(encodeValue(-15L)) + assertThat(evaluate(floor(constant(0L))).value).isEqualTo(encodeValue(0L)) + assertThat(evaluate(floor(constant(15L))).value).isEqualTo(encodeValue(15L)) + assertThat(evaluate(floor(constant(Long.MAX_VALUE))).value) + .isEqualTo(encodeValue(Long.MAX_VALUE)) + } + + @Test + fun floorFunctionTestWithDouble() { + assertThat(evaluate(floor(constant(-15.0))).value).isEqualTo(encodeValue(-15.0)) + assertThat(evaluate(floor(constant(-0.4))).value).isEqualTo(encodeValue(-1.0)) + assertThat(evaluate(floor(constant(0.0))).value).isEqualTo(encodeValue(0.0)) + assertThat(evaluate(floor(constant(0.4))).value).isEqualTo(encodeValue(0.0)) + assertThat(evaluate(floor(constant(-0.0))).value).isEqualTo(encodeValue(-0.0)) + assertThat(evaluate(floor(constant(15.0))).value).isEqualTo(encodeValue(15.0)) + assertThat(evaluate(floor(constant(Double.MAX_VALUE))).value) + .isEqualTo(encodeValue(Double.MAX_VALUE)) + } + + @Test + fun floorFunctionTestWithNaN() { + assertThat(evaluate(floor(constant(Double.NaN))).value).isEqualTo(encodeValue(Double.NaN)) + } + + @Test + fun floorFunctionTestWithInfinity() { + assertThat(evaluate(floor(constant(Double.POSITIVE_INFINITY))).value) + .isEqualTo(encodeValue(Double.POSITIVE_INFINITY)) + assertThat(evaluate(floor(constant(Double.NEGATIVE_INFINITY))).value) + .isEqualTo(encodeValue(Double.NEGATIVE_INFINITY)) + } + + @Test + fun floorFunctionTestWithUnsupportedType() { + assertThat(evaluate(floor(constant("foo"))).isError).isTrue() + } + + // --- Pow Tests --- + @Test + fun powFunctionTest() { + assertThat(evaluate(pow(constant(2), constant(3))).value).isEqualTo(encodeValue(8.0)) + assertThat(evaluate(pow(constant(2L), constant(3))).value).isEqualTo(encodeValue(8.0)) + assertThat(evaluate(pow(constant(2.0), constant(3))).value).isEqualTo(encodeValue(8.0)) + assertThat(evaluate(pow(constant(2), constant(3L))).value).isEqualTo(encodeValue(8.0)) + assertThat(evaluate(pow(constant(2L), constant(3L))).value).isEqualTo(encodeValue(8.0)) + assertThat(evaluate(pow(constant(2.0), constant(3L))).value).isEqualTo(encodeValue(8.0)) + assertThat(evaluate(pow(constant(2), constant(3.0))).value).isEqualTo(encodeValue(8.0)) + assertThat(evaluate(pow(constant(2L), constant(3.0))).value).isEqualTo(encodeValue(8.0)) + assertThat(evaluate(pow(constant(2.0), constant(3.0))).value).isEqualTo(encodeValue(8.0)) + + assertThat(evaluate(pow(constant(2), constant(-3))).value).isEqualTo(encodeValue(1.0 / 8.0)) + assertThat(evaluate(pow(constant(2L), constant(-3))).value).isEqualTo(encodeValue(1.0 / 8.0)) + assertThat(evaluate(pow(constant(2.0), constant(-3))).value).isEqualTo(encodeValue(1.0 / 8.0)) + assertThat(evaluate(pow(constant(2), constant(-3L))).value).isEqualTo(encodeValue(1.0 / 8.0)) + assertThat(evaluate(pow(constant(2L), constant(-3L))).value).isEqualTo(encodeValue(1.0 / 8.0)) + assertThat(evaluate(pow(constant(2.0), constant(-3L))).value).isEqualTo(encodeValue(1.0 / 8.0)) + assertThat(evaluate(pow(constant(2), constant(-3.0))).value).isEqualTo(encodeValue(1.0 / 8.0)) + assertThat(evaluate(pow(constant(2L), constant(-3.0))).value).isEqualTo(encodeValue(1.0 / 8.0)) + assertThat(evaluate(pow(constant(2.0), constant(-3.0))).value).isEqualTo(encodeValue(1.0 / 8.0)) + + assertThat(evaluate(pow(constant(-2), constant(-3))).value).isEqualTo(encodeValue(-1.0 / 8.0)) + assertThat(evaluate(pow(constant(-2L), constant(-3))).value).isEqualTo(encodeValue(-1.0 / 8.0)) + assertThat(evaluate(pow(constant(-2.0), constant(-3))).value).isEqualTo(encodeValue(-1.0 / 8.0)) + assertThat(evaluate(pow(constant(-2), constant(-3L))).value).isEqualTo(encodeValue(-1.0 / 8.0)) + assertThat(evaluate(pow(constant(-2L), constant(-3L))).value).isEqualTo(encodeValue(-1.0 / 8.0)) + assertThat(evaluate(pow(constant(-2.0), constant(-3L))).value) + .isEqualTo(encodeValue(-1.0 / 8.0)) + assertThat(evaluate(pow(constant(-2), constant(-3.0))).value).isEqualTo(encodeValue(-1.0 / 8.0)) + assertThat(evaluate(pow(constant(-2L), constant(-3.0))).value) + .isEqualTo(encodeValue(-1.0 / 8.0)) + assertThat(evaluate(pow(constant(-2.0), constant(-3.0))).value) + .isEqualTo(encodeValue(-1.0 / 8.0)) + + assertThat(evaluate(pow(constant(1.0), constant(2))).value).isEqualTo(encodeValue(1.0)) + assertThat(evaluate(pow(constant(1.0), constant(2L))).value).isEqualTo(encodeValue(1.0)) + assertThat(evaluate(pow(constant(1.0), constant(2.5))).value).isEqualTo(encodeValue(1.0)) + assertThat(evaluate(pow(constant(1.0), constant(-2))).value).isEqualTo(encodeValue(1.0)) + assertThat(evaluate(pow(constant(1.0), constant(-2L))).value).isEqualTo(encodeValue(1.0)) + assertThat(evaluate(pow(constant(1.0), constant(-2.5))).value).isEqualTo(encodeValue(1.0)) + assertThat(evaluate(pow(constant(1.0), constant(Double.NaN))).value).isEqualTo(encodeValue(1.0)) + assertThat(evaluate(pow(constant(1.0), constant(Double.POSITIVE_INFINITY))).value) + .isEqualTo(encodeValue(1.0)) + assertThat(evaluate(pow(constant(1.0), constant(Double.NEGATIVE_INFINITY))).value) + .isEqualTo(encodeValue(1.0)) + + assertThat(evaluate(pow(constant(0), constant(2))).value).isEqualTo(encodeValue(0.0)) + assertThat(evaluate(pow(constant(0), constant(2L))).value).isEqualTo(encodeValue(0.0)) + assertThat(evaluate(pow(constant(0), constant(2.5))).value).isEqualTo(encodeValue(0.0)) + assertThat(evaluate(pow(constant(-0.0), constant(2))).value).isEqualTo(encodeValue(0.0)) + + assertThat(evaluate(pow(constant(2), constant(0))).value).isEqualTo(encodeValue(1.0)) + assertThat(evaluate(pow(constant(2L), constant(0))).value).isEqualTo(encodeValue(1.0)) + assertThat(evaluate(pow(constant(2.5), constant(0))).value).isEqualTo(encodeValue(1.0)) + assertThat(evaluate(pow(constant(-2), constant(0))).value).isEqualTo(encodeValue(1.0)) + assertThat(evaluate(pow(constant(-2L), constant(0))).value).isEqualTo(encodeValue(1.0)) + assertThat(evaluate(pow(constant(-2.5), constant(0))).value).isEqualTo(encodeValue(1.0)) + assertThat(evaluate(pow(constant(Double.NaN), constant(0))).value).isEqualTo(encodeValue(1.0)) + assertThat(evaluate(pow(constant(Double.POSITIVE_INFINITY), constant(0))).value) + .isEqualTo(encodeValue(1.0)) + assertThat(evaluate(pow(constant(Double.NEGATIVE_INFINITY), constant(0))).value) + .isEqualTo(encodeValue(1.0)) + + assertThat(evaluate(pow(constant(2.0), constant(Integer.MAX_VALUE))).value) + .isEqualTo(encodeValue(Double.POSITIVE_INFINITY)) + assertThat(evaluate(pow(constant(2.0), constant(Long.MAX_VALUE))).value) + .isEqualTo(encodeValue(Double.POSITIVE_INFINITY)) + assertThat(evaluate(pow(constant(2), constant(Long.MAX_VALUE))).value) + .isEqualTo(encodeValue(Double.POSITIVE_INFINITY)) + assertThat(evaluate(pow(constant(2L), constant(Long.MAX_VALUE))).value) + .isEqualTo(encodeValue(Double.POSITIVE_INFINITY)) + + assertThat(evaluate(pow(constant(2.0), constant(Integer.MIN_VALUE))).value) + .isEqualTo(encodeValue(0.0)) + assertThat(evaluate(pow(constant(2.0), constant(Long.MIN_VALUE))).value) + .isEqualTo(encodeValue(0.0)) + assertThat(evaluate(pow(constant(2.0), constant(-Double.MAX_VALUE))).value) + .isEqualTo(encodeValue(0.0)) + assertThat(evaluate(pow(constant(2), constant(Integer.MIN_VALUE))).value) + .isEqualTo(encodeValue(0.0)) + assertThat(evaluate(pow(constant(2), constant(Long.MIN_VALUE))).value) + .isEqualTo(encodeValue(0.0)) + assertThat(evaluate(pow(constant(2), constant(-Double.MAX_VALUE))).value) + .isEqualTo(encodeValue(0.0)) + assertThat(evaluate(pow(constant(2L), constant(Integer.MIN_VALUE))).value) + .isEqualTo(encodeValue(0.0)) + assertThat(evaluate(pow(constant(2L), constant(Long.MIN_VALUE))).value) + .isEqualTo(encodeValue(0.0)) + assertThat(evaluate(pow(constant(2L), constant(-Double.MAX_VALUE))).value) + .isEqualTo(encodeValue(0.0)) + } + + @Test + fun powFunctionTestWithInfiniteSemantics() { + assertThat(evaluate(pow(constant(-1), constant(Double.POSITIVE_INFINITY))).value) + .isEqualTo(encodeValue(1.0)) + assertThat(evaluate(pow(constant(-1L), constant(Double.POSITIVE_INFINITY))).value) + .isEqualTo(encodeValue(1.0)) + assertThat(evaluate(pow(constant(-1.0), constant(Double.POSITIVE_INFINITY))).value) + .isEqualTo(encodeValue(1.0)) + + assertThat(evaluate(pow(constant(-1), constant(Double.NEGATIVE_INFINITY))).value) + .isEqualTo(encodeValue(1.0)) + assertThat(evaluate(pow(constant(-1L), constant(Double.NEGATIVE_INFINITY))).value) + .isEqualTo(encodeValue(1.0)) + assertThat(evaluate(pow(constant(-1.0), constant(Double.NEGATIVE_INFINITY))).value) + .isEqualTo(encodeValue(1.0)) + + assertThat(evaluate(pow(constant(0.5), constant(Double.NEGATIVE_INFINITY))).value) + .isEqualTo(encodeValue(Double.POSITIVE_INFINITY)) + + assertThat(evaluate(pow(constant(0.0), constant(Double.NEGATIVE_INFINITY))).isError).isTrue() + assertThat(evaluate(pow(constant(-0.0), constant(Double.NEGATIVE_INFINITY))).isError).isTrue() + + assertThat(evaluate(pow(constant(2), constant(Double.NEGATIVE_INFINITY))).value) + .isEqualTo(encodeValue(0.0)) + assertThat(evaluate(pow(constant(2L), constant(Double.NEGATIVE_INFINITY))).value) + .isEqualTo(encodeValue(0.0)) + assertThat(evaluate(pow(constant(2.5), constant(Double.NEGATIVE_INFINITY))).value) + .isEqualTo(encodeValue(0.0)) + + assertThat(evaluate(pow(constant(0.5), constant(Double.POSITIVE_INFINITY))).value) + .isEqualTo(encodeValue(0.0)) + + assertThat(evaluate(pow(constant(2), constant(Double.POSITIVE_INFINITY))).value) + .isEqualTo(encodeValue(Double.POSITIVE_INFINITY)) + assertThat(evaluate(pow(constant(2L), constant(Double.POSITIVE_INFINITY))).value) + .isEqualTo(encodeValue(Double.POSITIVE_INFINITY)) + assertThat(evaluate(pow(constant(2.5), constant(Double.POSITIVE_INFINITY))).value) + .isEqualTo(encodeValue(Double.POSITIVE_INFINITY)) + + assertThat(evaluate(pow(constant(Double.NEGATIVE_INFINITY), constant(-2))).value) + .isEqualTo(encodeValue(0.0)) + assertThat(evaluate(pow(constant(Double.NEGATIVE_INFINITY), constant(-2L))).value) + .isEqualTo(encodeValue(0.0)) + assertThat(evaluate(pow(constant(Double.NEGATIVE_INFINITY), constant(-2.5))).value) + .isEqualTo(encodeValue(0.0)) + + assertThat(evaluate(pow(constant(Double.NEGATIVE_INFINITY), constant(3))).value) + .isEqualTo(encodeValue(Double.NEGATIVE_INFINITY)) + assertThat(evaluate(pow(constant(Double.NEGATIVE_INFINITY), constant(3L))).value) + .isEqualTo(encodeValue(Double.NEGATIVE_INFINITY)) + assertThat(evaluate(pow(constant(Double.NEGATIVE_INFINITY), constant(3.0))).value) + .isEqualTo(encodeValue(Double.NEGATIVE_INFINITY)) + + assertThat(evaluate(pow(constant(Double.NEGATIVE_INFINITY), constant(2))).value) + .isEqualTo(encodeValue(Double.POSITIVE_INFINITY)) + assertThat(evaluate(pow(constant(Double.NEGATIVE_INFINITY), constant(2L))).value) + .isEqualTo(encodeValue(Double.POSITIVE_INFINITY)) + assertThat(evaluate(pow(constant(Double.NEGATIVE_INFINITY), constant(2.0))).value) + .isEqualTo(encodeValue(Double.POSITIVE_INFINITY)) + + assertThat(evaluate(pow(constant(Double.NEGATIVE_INFINITY), constant(3.1))).value) + .isEqualTo(encodeValue(Double.POSITIVE_INFINITY)) + + assertThat(evaluate(pow(constant(Double.POSITIVE_INFINITY), constant(-2))).value) + .isEqualTo(encodeValue(0.0)) + assertThat(evaluate(pow(constant(Double.POSITIVE_INFINITY), constant(-2L))).value) + .isEqualTo(encodeValue(0.0)) + assertThat(evaluate(pow(constant(Double.POSITIVE_INFINITY), constant(-0.5))).value) + .isEqualTo(encodeValue(0.0)) + + assertThat(evaluate(pow(constant(Double.POSITIVE_INFINITY), constant(3))).value) + .isEqualTo(encodeValue(Double.POSITIVE_INFINITY)) + assertThat(evaluate(pow(constant(Double.POSITIVE_INFINITY), constant(3L))).value) + .isEqualTo(encodeValue(Double.POSITIVE_INFINITY)) + assertThat(evaluate(pow(constant(Double.POSITIVE_INFINITY), constant(0.5))).value) + .isEqualTo(encodeValue(Double.POSITIVE_INFINITY)) + } + + @Test + fun powFunctionTestWithErrorSemantics() { + assertThat(evaluate(pow(constant(-1), constant(3.1))).isError).isTrue() + assertThat(evaluate(pow(constant(-1L), constant(3.1))).isError).isTrue() + assertThat(evaluate(pow(constant(-0.5), constant(3.1))).isError).isTrue() + + assertThat(evaluate(pow(constant(0), constant(-2))).isError).isTrue() + assertThat(evaluate(pow(constant(0), constant(-2L))).isError).isTrue() + assertThat(evaluate(pow(constant(0), constant(-2.5))).isError).isTrue() + assertThat(evaluate(pow(constant(-0.0), constant(-2))).isError).isTrue() + + assertThat(evaluate(pow(constant(Double.NaN), constant(3))).value) + .isEqualTo(encodeValue(Double.NaN)) + assertThat(evaluate(pow(constant(Double.NaN), constant(3L))).value) + .isEqualTo(encodeValue(Double.NaN)) + assertThat(evaluate(pow(constant(Double.NaN), constant(3.1))).value) + .isEqualTo(encodeValue(Double.NaN)) + assertThat(evaluate(pow(constant(Double.NaN), constant(-3))).value) + .isEqualTo(encodeValue(Double.NaN)) + assertThat(evaluate(pow(constant(Double.NaN), constant(-3L))).value) + .isEqualTo(encodeValue(Double.NaN)) + assertThat(evaluate(pow(constant(Double.NaN), constant(-3.1))).value) + .isEqualTo(encodeValue(Double.NaN)) + + assertThat(evaluate(pow(constant(2), constant(Double.NaN))).value) + .isEqualTo(encodeValue(Double.NaN)) + assertThat(evaluate(pow(constant(2L), constant(Double.NaN))).value) + .isEqualTo(encodeValue(Double.NaN)) + assertThat(evaluate(pow(constant(2.5), constant(Double.NaN))).value) + .isEqualTo(encodeValue(Double.NaN)) + assertThat(evaluate(pow(constant(0), constant(Double.NaN))).value) + .isEqualTo(encodeValue(Double.NaN)) + assertThat(evaluate(pow(constant(-0.0), constant(Double.NaN))).value) + .isEqualTo(encodeValue(Double.NaN)) + assertThat(evaluate(pow(constant(-2), constant(Double.NaN))).value) + .isEqualTo(encodeValue(Double.NaN)) + assertThat(evaluate(pow(constant(-2L), constant(Double.NaN))).value) + .isEqualTo(encodeValue(Double.NaN)) + assertThat(evaluate(pow(constant(-2.5), constant(Double.NaN))).value) + .isEqualTo(encodeValue(Double.NaN)) + + assertThat(evaluate(pow(constant("abc"), constant(3))).isError).isTrue() + assertThat(evaluate(pow(constant(3L), constant("abc"))).isError).isTrue() + } + + // --- Round Tests --- + @Test + fun roundFunctionTest() { + assertThat(evaluate(round(constant(15.48924))).value).isEqualTo(encodeValue(15.0)) + } + + @Test + fun roundFunctionTestWithZero() { + assertThat(evaluate(round(constant(0L))).value).isEqualTo(encodeValue(0L)) + } + + @Test + fun roundFunctionTestPositiveHalfway() { + assertThat(evaluate(round(constant(15.5))).value).isEqualTo(encodeValue(16.0)) + } + + @Test + fun roundFunctionTestNegativeHalfway() { + assertThat(evaluate(round(constant(-15.5))).value).isEqualTo(encodeValue(-16.0)) + } + + @Test + fun roundFunctionTestWithMaxDouble() { + assertThat(evaluate(round(constant(Double.MAX_VALUE))).value) + .isEqualTo(encodeValue(Double.MAX_VALUE)) + } + + @Test + fun roundFunctionTestWithMaxNegativeDouble() { + assertThat(evaluate(round(constant(-Double.MAX_VALUE))).value) + .isEqualTo(encodeValue(-Double.MAX_VALUE)) + } + + @Test + fun roundFunctionTestWithLong() { + assertThat(evaluate(round(constant(0L))).value).isEqualTo(encodeValue(0L)) + } + + @Test + fun roundFunctionTestWithMaxLong() { + assertThat(evaluate(round(constant(Long.MAX_VALUE))).value) + .isEqualTo(encodeValue(Long.MAX_VALUE)) + } + + @Test + fun roundFunctionTestWithMaxLongNegative() { + assertThat(evaluate(round(constant(-Long.MAX_VALUE))).value) + .isEqualTo(encodeValue(-Long.MAX_VALUE)) + } + + @Test + fun roundFunctionTestWithInteger() { + assertThat(evaluate(round(constant(15))).value).isEqualTo(encodeValue(15L)) + } + + @Test + fun roundFunctionTestWithMaxInteger() { + assertThat(evaluate(round(constant(Integer.MAX_VALUE))).value) + .isEqualTo(encodeValue(Integer.MAX_VALUE.toLong())) + } + + @Test + fun roundFunctionTestWithMaxIntegerNegative() { + assertThat(evaluate(round(constant(-Integer.MAX_VALUE))).value) + .isEqualTo(encodeValue((-Integer.MAX_VALUE).toLong())) + } + + @Test + fun roundFunctionTestWithInfinity() { + assertThat(evaluate(round(constant(Double.POSITIVE_INFINITY))).value) + .isEqualTo(encodeValue(Double.POSITIVE_INFINITY)) + } + + @Test + fun roundFunctionTestWithNegativeInfinity() { + assertThat(evaluate(round(constant(Double.NEGATIVE_INFINITY))).value) + .isEqualTo(encodeValue(Double.NEGATIVE_INFINITY)) + } + + @Test + fun roundFunctionTestWithNaN() { + assertThat(evaluate(round(constant(Double.NaN))).value).isEqualTo(encodeValue(Double.NaN)) + } + + @Test + fun roundFunctionTestWithZeroDouble() { + assertThat(evaluate(round(constant(0.0))).value).isEqualTo(encodeValue(0.0)) + } + + @Test + fun roundFunctionTestWithNegativeZero() { + assertThat(evaluate(round(constant(-0.0))).value).isEqualTo(encodeValue(0.0)) + } + + @Test + fun roundFunctionTestWithUnknownValueType() { + assertThat(evaluate(round(constant("foo"))).isError).isTrue() + } + + // --- Sqrt Tests --- + @Test + fun sqrtFunctionTestWithInteger() { + assertThat(evaluate(sqrt(constant(16))).value).isEqualTo(encodeValue(4.0)) + } + + @Test + fun sqrtFunctionTestWithNegativeInteger() { + assertThat(evaluate(sqrt(constant(-16))).isError).isTrue() + } + + @Test + fun sqrtFunctionTestWithLong() { + assertThat(evaluate(sqrt(constant(16L))).value).isEqualTo(encodeValue(4.0)) + } + + @Test + fun sqrtFunctionTestWithNegativeLong() { + assertThat(evaluate(sqrt(constant(-16L))).isError).isTrue() + } + + @Test + fun sqrtFunctionTestWithDouble() { + assertThat(evaluate(sqrt(constant(16.0))).value).isEqualTo(encodeValue(4.0)) + } + + @Test + fun sqrtFunctionTestWithNegativeDouble() { + assertThat(evaluate(sqrt(constant(-16.0))).isError).isTrue() + } + + @Test + fun sqrtFunctionTestWithZeroDouble() { + assertThat(evaluate(sqrt(constant(0.0))).value).isEqualTo(encodeValue(0.0)) + } + + @Test + fun sqrtFunctionTestWithNegativeZeroDouble() { + assertThat(evaluate(sqrt(constant(-0.0))).value).isEqualTo(encodeValue(-0.0)) + } + + @Test + fun sqrtFunctionTestWithInfinity() { + assertThat(evaluate(sqrt(constant(Double.POSITIVE_INFINITY))).value) + .isEqualTo(encodeValue(Double.POSITIVE_INFINITY)) + } + + @Test + fun sqrtFunctionTestWithNegativeInfinity() { + assertThat(evaluate(sqrt(constant(Double.NEGATIVE_INFINITY))).isError).isTrue() + } + + @Test + fun sqrtFunctionTestWithNaN() { + assertThat(evaluate(sqrt(constant(Double.NaN))).value).isEqualTo(encodeValue(Double.NaN)) + } + + @Test + fun sqrtFunctionTestWithUnsupportedType() { + assertThat(evaluate(sqrt(constant("foo"))).isError).isTrue() + } } diff --git a/firebase-firestore/src/test/java/com/google/firebase/firestore/pipeline/MirroringSemanticsTests.kt b/firebase-firestore/src/test/java/com/google/firebase/firestore/pipeline/MirroringSemanticsTests.kt index 716b1d9c903..6737959cb60 100644 --- a/firebase-firestore/src/test/java/com/google/firebase/firestore/pipeline/MirroringSemanticsTests.kt +++ b/firebase-firestore/src/test/java/com/google/firebase/firestore/pipeline/MirroringSemanticsTests.kt @@ -14,6 +14,7 @@ package com.google.firebase.firestore.pipeline +import com.google.firebase.firestore.pipeline.Expr.Companion.abs import com.google.firebase.firestore.pipeline.Expr.Companion.add import com.google.firebase.firestore.pipeline.Expr.Companion.arrayContains import com.google.firebase.firestore.pipeline.Expr.Companion.arrayContainsAll @@ -26,12 +27,16 @@ import com.google.firebase.firestore.pipeline.Expr.Companion.divide import com.google.firebase.firestore.pipeline.Expr.Companion.endsWith import com.google.firebase.firestore.pipeline.Expr.Companion.eq import com.google.firebase.firestore.pipeline.Expr.Companion.eqAny +import com.google.firebase.firestore.pipeline.Expr.Companion.exp import com.google.firebase.firestore.pipeline.Expr.Companion.field import com.google.firebase.firestore.pipeline.Expr.Companion.gt import com.google.firebase.firestore.pipeline.Expr.Companion.gte import com.google.firebase.firestore.pipeline.Expr.Companion.isNan import com.google.firebase.firestore.pipeline.Expr.Companion.isNotNan import com.google.firebase.firestore.pipeline.Expr.Companion.like +import com.google.firebase.firestore.pipeline.Expr.Companion.ln +import com.google.firebase.firestore.pipeline.Expr.Companion.log +import com.google.firebase.firestore.pipeline.Expr.Companion.log10 import com.google.firebase.firestore.pipeline.Expr.Companion.lt import com.google.firebase.firestore.pipeline.Expr.Companion.lte import com.google.firebase.firestore.pipeline.Expr.Companion.mod @@ -39,9 +44,11 @@ import com.google.firebase.firestore.pipeline.Expr.Companion.multiply import com.google.firebase.firestore.pipeline.Expr.Companion.neq import com.google.firebase.firestore.pipeline.Expr.Companion.notEqAny import com.google.firebase.firestore.pipeline.Expr.Companion.nullValue +import com.google.firebase.firestore.pipeline.Expr.Companion.pow import com.google.firebase.firestore.pipeline.Expr.Companion.regexContains import com.google.firebase.firestore.pipeline.Expr.Companion.regexMatch import com.google.firebase.firestore.pipeline.Expr.Companion.reverse +import com.google.firebase.firestore.pipeline.Expr.Companion.sqrt import com.google.firebase.firestore.pipeline.Expr.Companion.startsWith import com.google.firebase.firestore.pipeline.Expr.Companion.strConcat import com.google.firebase.firestore.pipeline.Expr.Companion.strContains @@ -92,6 +99,11 @@ internal class MirroringSemanticsTests { fun `unary function input mirroring`() { val unaryFunctionBuilders = listOf Expr>>( + "abs" to { v -> abs(v) }, + "exp" to { v -> exp(v) }, + "ln" to { v -> ln(v) }, + "log10" to { v -> log10(v) }, + "sqrt" to { v -> sqrt(v) }, "isNan" to { v -> isNan(v) }, "isNotNan" to { v -> isNotNan(v) }, "arrayLength" to { v -> arrayLength(v) }, @@ -147,6 +159,8 @@ internal class MirroringSemanticsTests { "multiply" to { v1, v2 -> multiply(v1, v2) }, "divide" to { v1, v2 -> divide(v1, v2) }, "mod" to { v1, v2 -> mod(v1, v2) }, + "log" to { v1, v2 -> log(v1, v2) }, + "pow" to { v1, v2 -> pow(v1, v2) }, // Comparison "eq" to { v1, v2 -> eq(v1, v2) }, "neq" to { v1, v2 -> neq(v1, v2) }, diff --git a/firebase-firestore/src/test/java/com/google/firebase/firestore/pipeline/StringTests.kt b/firebase-firestore/src/test/java/com/google/firebase/firestore/pipeline/StringTests.kt index 2885637427a..b0ea61da571 100644 --- a/firebase-firestore/src/test/java/com/google/firebase/firestore/pipeline/StringTests.kt +++ b/firebase-firestore/src/test/java/com/google/firebase/firestore/pipeline/StringTests.kt @@ -29,10 +29,12 @@ import com.google.firebase.firestore.pipeline.Expr.Companion.reverse import com.google.firebase.firestore.pipeline.Expr.Companion.startsWith import com.google.firebase.firestore.pipeline.Expr.Companion.strConcat import com.google.firebase.firestore.pipeline.Expr.Companion.strContains +import com.google.firebase.firestore.pipeline.Expr.Companion.substr import com.google.firebase.firestore.pipeline.Expr.Companion.toLower import com.google.firebase.firestore.pipeline.Expr.Companion.toUpper import com.google.firebase.firestore.pipeline.Expr.Companion.trim import com.google.firebase.firestore.testutil.TestUtilKtx.doc +import com.google.protobuf.ByteString import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner @@ -922,4 +924,273 @@ internal class StringTests { val expr = reverse(nullValue()) assertEvaluatesToNull(evaluate(expr), "reverse(null)") } + + @Test + fun substr_onString_returnsSubstring() { + val expr = substr(constant("abc"), constant(1L), constant(2L)) + assertEvaluatesTo(evaluate(expr), encodeValue("bc"), "substr(\"abc\", 1, 2)") + } + + @Test + fun substr_onString_largePosition_returnsEmptyString() { + val expr = substr(constant("abc"), constant(Long.MAX_VALUE), constant(1L)) + assertEvaluatesTo(evaluate(expr), encodeValue(""), "substr('abc', Long.MAX_VALUE, 1)") + } + + @Test + fun substr_onString_positionOnLast_returnsLastCharacter() { + val expr = substr(constant("abc"), constant(2L), constant(2L)) + assertEvaluatesTo(evaluate(expr), encodeValue("c"), "substr(\"abc\", 2, 2)") + } + + @Test + fun substr_onString_positionPastLast_returnsEmptyString() { + val expr = substr(constant("abc"), constant(3L), constant(2L)) + assertEvaluatesTo(evaluate(expr), encodeValue(""), "substr(\"abc\", 3, 2)") + } + + @Test + fun substr_onString_positionOnZero_startsFromZero() { + val expr = substr(constant("abc"), constant(0L), constant(6L)) + assertEvaluatesTo(evaluate(expr), encodeValue("abc"), "substr(\"abc\", 0, 6)") + } + + @Test + fun substr_onString_oversizedLength_returnsTruncatedString() { + val expr = substr(constant("abc"), constant(1L), constant(Long.MAX_VALUE)) + assertEvaluatesTo(evaluate(expr), encodeValue("bc"), "substr(\"abc\", 1, Long.MAX_VALUE)") + } + + @Test + fun substr_onString_negativePosition() { + val expr = substr(constant("abcd"), constant(-3L), constant(2L)) + assertEvaluatesTo(evaluate(expr), encodeValue("bc"), "substr(\"abcd\", -3, 2)") + } + + @Test + fun substr_onString_negativePosition_startsFromLast() { + val expr = substr(constant("abc"), constant(-1L), constant(1L)) + assertEvaluatesTo(evaluate(expr), encodeValue("c"), "substr(\"abc\", -1, 1)") + } + + @Test + fun substr_onCodePoints_negativePosition_startsFromLast() { + val expr = substr(constant("γ‰‡πŸ€„"), constant(-1L), constant(1L)) + assertEvaluatesTo(evaluate(expr), encodeValue("πŸ€„"), "substr(\"γ‰‡πŸ€„\", -1, 1)") + } + + @Test + fun substr_onString_maxNegativePosition_startsFromZero() { + val expr = substr(blob("abc".toByteArray()), constant(-Long.MAX_VALUE), constant(2L)) + assertEvaluatesTo( + evaluate(expr), + encodeValue(com.google.firebase.firestore.Blob.fromBytes("ab".toByteArray())), + "substr(blob(abc), -Long.MAX_VALUE, 2)" + ) + } + + @Test + fun substr_onString_oversizedNegativePosition_startsFromZero() { + val expr = substr(blob("abc".toByteArray()), constant(-4L), constant(2L)) + assertEvaluatesTo( + evaluate(expr), + encodeValue(com.google.firebase.firestore.Blob.fromBytes("ab".toByteArray())), + "substr(blob(abc), -4, 2)" + ) + } + + @Test + fun substr_onNonAsciiString() { + val expr = substr(constant("Ο–Ο—Ο "), constant(1L), constant(1L)) + assertEvaluatesTo(evaluate(expr), encodeValue("Ο—"), "substr(\"Ο–Ο—Ο \", 1, 1)") + } + + @Test + fun substr_onCharacterDecomposition_treatedAsSeparateCharacters() { + val umlaut = String(charArrayOf(0x0308.toChar())) + val decomposedChar = "u" + umlaut + + // Assert that the component characters of a decomposed character are trimmed correctly. + val expr1 = substr(constant(decomposedChar), constant(1), constant(2)) + assertEvaluatesTo(evaluate(expr1), encodeValue(umlaut), "substr(decomposed, 1, 2)") + + val expr2 = substr(constant(decomposedChar), constant(0), constant(1)) + assertEvaluatesTo(evaluate(expr2), encodeValue("u"), "substr(decomposed, 0, 1)") + } + + @Test + fun substr_onComposedCharacter_treatedAsSingleCharacter() { + val expr1 = substr(constant("ΓΌ"), constant(1), constant(1)) + assertEvaluatesTo(evaluate(expr1), encodeValue(""), "substr(\"ΓΌ\", 1, 1)") + + val expr2 = substr(constant("ΓΌ"), constant(0), constant(1)) + assertEvaluatesTo(evaluate(expr2), encodeValue("ΓΌ"), "substr(\"ΓΌ\", 0, 1)") + } + + @Test + fun substr_mixedAsciiNonAsciiString_returnsSubstring() { + val expr = substr(constant("aΟ—bΟ–Ο—Ο c"), constant(1), constant(3)) + assertEvaluatesTo(evaluate(expr), encodeValue("Ο—bΟ–"), "substr(\"aΟ—bΟ–Ο—Ο c\", 1, 3)") + } + + @Test + fun substr_mixedAsciiNonAsciiString_afterNonAscii() { + val expr = substr(constant("aΟ—bΟ–Ο—Ο c"), constant(4), constant(2)) + assertEvaluatesTo(evaluate(expr), encodeValue("Ο—Ο "), "substr(\"aΟ—bΟ–Ο—Ο c\", 4, 2)") + } + + @Test + fun substr_onString_negativeLength_throws() { + val expr = substr(blob("abc".toByteArray()), constant(1L), constant(-1L)) + assertEvaluatesToError(evaluate(expr), "substr with negative length") + } + + @Test + fun substr_onBytes_returnsSubstring() { + val expr = substr(blob("abc".toByteArray()), constant(1L), constant(2L)) + assertEvaluatesTo( + evaluate(expr), + encodeValue(com.google.firebase.firestore.Blob.fromBytes("bc".toByteArray())), + "substr(blob(abc), 1, 2)" + ) + } + + @Test + fun substr_onBytes_returnsInvalidUTF8Substring() { + val expr = + substr( + blob(ByteString.fromHex("F9FAFB").toByteArray()), + constant(1L), + constant(Long.MAX_VALUE) + ) + assertEvaluatesTo( + evaluate(expr), + encodeValue(com.google.firebase.firestore.Blob.fromByteString(ByteString.fromHex("FAFB"))), + "substr invalid utf8" + ) + } + + @Test + fun substr_onCodePoints_returnsSubstring() { + val codePoints = "πŸŒŽγ‰‡πŸ€„β›Ή" + val expr = substr(constant(codePoints), constant(1L), constant(2L)) + assertEvaluatesTo(evaluate(expr), encodeValue("γ‰‡πŸ€„"), "substr(\"πŸŒŽγ‰‡πŸ€„β›Ή\", 1, 2)") + } + + @Test + fun substr_onCodePoints_andAscii_returnsSubstring() { + val codePoints = "πŸŒŽγ‰‡fooπŸ€„barβ›Ή" + val expr = substr(constant(codePoints), constant(4L), constant(4L)) + assertEvaluatesTo(evaluate(expr), encodeValue("oπŸ€„ba"), "substr(\"πŸŒŽγ‰‡fooπŸ€„barβ›Ή\", 4, 4)") + } + + @Test + fun substr_onCodePoints_oversizedLength_returnsSubstring() { + val codePoints = "πŸŒŽγ‰‡πŸ€„β›Ή" + val expr = substr(constant(codePoints), constant(1L), constant(6L)) + assertEvaluatesTo(evaluate(expr), encodeValue("γ‰‡πŸ€„β›Ή"), "substr(\"πŸŒŽγ‰‡πŸ€„β›Ή\", 1, 6)") + } + + @Test + fun substr_onCodePoints_startingAtZero_returnsSubstring() { + val codePoints = "πŸŒŽγ‰‡πŸ€„β›Ή" + val expr = substr(constant(codePoints), constant(0L), constant(3L)) + assertEvaluatesTo(evaluate(expr), encodeValue("πŸŒŽγ‰‡πŸ€„"), "substr(\"πŸŒŽγ‰‡πŸ€„β›Ή\", 0, 3)") + } + + @Test + fun substr_onSingleCodePointGrapheme_doesNotSplit() { + val expr1 = substr(constant("πŸ––"), constant(0L), constant(1L)) + assertEvaluatesTo(evaluate(expr1), encodeValue("πŸ––"), "substr(\"πŸ––\", 0, 1)") + val expr2 = substr(constant("πŸ––"), constant(1L), constant(1L)) + assertEvaluatesTo(evaluate(expr2), encodeValue(""), "substr(\"πŸ––\", 1, 1)") + } + + @Test + fun substr_onMultiCodePointGrapheme_splitsGrapheme() { + val expr1 = substr(constant("πŸ––πŸ»"), constant(0L), constant(1L)) + assertEvaluatesTo(evaluate(expr1), encodeValue("πŸ––"), "substr(\"πŸ––πŸ»\", 0, 1)") + // Asserting that when the second half is split, it only returns the skin tone code point. + val expr2 = substr(constant("πŸ––πŸ»"), constant(1L), constant(1L)) + val skinTone = String(charArrayOf(0xD83C.toChar(), 0xDFFB.toChar())) + assertEvaluatesTo(evaluate(expr2), encodeValue(skinTone), "substr(\"πŸ––πŸ»\", 1, 1)") + } + + @Test + fun substr_onBytes_largePosition_returnsEmptyString() { + val expr = substr(blob("abc".toByteArray()), constant(Long.MAX_VALUE), constant(3L)) + assertEvaluatesTo( + evaluate(expr), + encodeValue(com.google.firebase.firestore.Blob.fromByteString(ByteString.EMPTY)), + "substr(blob(abc), Long.MAX_VALUE, 3)" + ) + } + + @Test + fun substr_onBytes_positionOnLast_returnsLastByte() { + val expr = substr(blob("abc".toByteArray()), constant(2L), constant(2L)) + assertEvaluatesTo( + evaluate(expr), + encodeValue(com.google.firebase.firestore.Blob.fromBytes("c".toByteArray())), + "substr(blob(abc), 2, 2)" + ) + } + + @Test + fun substr_onBytes_positionPastLast_returnsEmptyByteString() { + val expr = substr(blob("abc".toByteArray()), constant(3L), constant(2L)) + assertEvaluatesTo( + evaluate(expr), + encodeValue(com.google.firebase.firestore.Blob.fromByteString(ByteString.EMPTY)), + "substr(blob(abc), 3, 2)" + ) + } + + @Test + fun substr_onBytes_positionOnZero_startsFromZero() { + val expr = substr(blob("abc".toByteArray()), constant(0L), constant(6L)) + assertEvaluatesTo( + evaluate(expr), + encodeValue(com.google.firebase.firestore.Blob.fromBytes("abc".toByteArray())), + "substr(blob(abc), 0, 6)" + ) + } + + @Test + fun substr_onBytes_negativePosition_startsFromLast() { + val expr = substr(blob("abc".toByteArray()), constant(-1L), constant(1L)) + assertEvaluatesTo( + evaluate(expr), + encodeValue(com.google.firebase.firestore.Blob.fromBytes("c".toByteArray())), + "substr(blob(abc), -1, 1)" + ) + } + + @Test + fun substr_onBytes_oversizedNegativePosition_startsFromZero() { + val expr = substr(blob("abc".toByteArray()), constant(-Long.MAX_VALUE), constant(3L)) + assertEvaluatesTo( + evaluate(expr), + encodeValue(com.google.firebase.firestore.Blob.fromBytes("abc".toByteArray())), + "substr(blob(abc), -Long.MAX_VALUE, 3)" + ) + } + + @Test + fun substr_unknownValueType_returnsError() { + val expr = substr(constant(20L), constant(4L), constant(1L)) + assertEvaluatesToError(evaluate(expr), "substr on non-string/blob") + } + + @Test + fun substr_unknownPositionType_returnsError() { + val expr = substr(constant("abc"), constant("foo"), constant(1L)) + assertEvaluatesToError(evaluate(expr), "substr with non-integer position") + } + + @Test + fun substr_unknownLengthType_returnsError() { + val expr = substr(constant("abc"), constant(1L), constant("foo")) + assertEvaluatesToError(evaluate(expr), "substr with non-integer length") + } } diff --git a/firebase-firestore/src/test/java/com/google/firebase/firestore/pipeline/testUtil.kt b/firebase-firestore/src/test/java/com/google/firebase/firestore/pipeline/testUtil.kt index 84d5fd809f8..edf9437c3f9 100644 --- a/firebase-firestore/src/test/java/com/google/firebase/firestore/pipeline/testUtil.kt +++ b/firebase-firestore/src/test/java/com/google/firebase/firestore/pipeline/testUtil.kt @@ -31,7 +31,7 @@ private val FAKE_USER_DATA_READER = UserDataReader(FAKE_DATABASE_ID) val EMPTY_DOC: MutableDocument = doc("foo/1", 0, mapOf()) internal val EVALUATION_CONTEXT: EvaluationContext = EvaluationContext( - RealtimePipeline(RemoteSerializer(FAKE_DATABASE_ID), FAKE_USER_DATA_READER, emptyList()) + RealtimePipeline(null, RemoteSerializer(FAKE_DATABASE_ID), FAKE_USER_DATA_READER, emptyList()) ) internal fun evaluate(expr: Expr): EvaluateResult = evaluate(expr, EMPTY_DOC)