diff --git a/apis/Google.Cloud.Firestore/Google.Cloud.Firestore.IntegrationTests/AggregateQueryTest.cs b/apis/Google.Cloud.Firestore/Google.Cloud.Firestore.IntegrationTests/AggregateQueryTest.cs index f40d4b4c7335..845b52b4c90b 100644 --- a/apis/Google.Cloud.Firestore/Google.Cloud.Firestore.IntegrationTests/AggregateQueryTest.cs +++ b/apis/Google.Cloud.Firestore/Google.Cloud.Firestore.IntegrationTests/AggregateQueryTest.cs @@ -86,6 +86,32 @@ public async Task Sum() Assert.Equal(double.NaN, snapshot.GetValue("Sum_EnglishScore")); // NaN value check } + [Fact] + public async Task Sum_Explain() + { + CollectionReference collection = _fixture.StudentCollection; + var plan = await collection + .Aggregate(AggregateField.Sum("Level", "Sum_Of_Levels"), AggregateField.Sum("MathScore"), AggregateField.Sum("EnglishScore"), AggregateField.Sum("Name")) + .ExplainAsync(); + Assert.NotNull(plan); + } + + [Fact] + public async Task Sum_ExplainAnalyze() + { + CollectionReference collection = _fixture.StudentCollection; + var queryProfileInfo = await collection.Aggregate(AggregateField.Sum("Level", "Sum_Of_Levels"), AggregateField.Sum("MathScore"), AggregateField.Sum("EnglishScore"), AggregateField.Sum("Name")) + .ExplainAnalyzeAsync(); + var plan = queryProfileInfo.Plan; + var stats = queryProfileInfo.Stats; + Assert.NotNull(plan); + Assert.NotNull(stats); + var snapshot = queryProfileInfo.Snapshot; + Assert.Equal(Student.Data.Sum(c => c.Level), snapshot.GetValue("Sum_Of_Levels")); // Long value, Alias check + Assert.Equal(Student.Data.Sum(c => c.MathScore), snapshot.GetValue("Sum_MathScore")); // Double value check + Assert.Equal(double.NaN, snapshot.GetValue("Sum_EnglishScore")); // NaN value check + } + [Fact] public async Task Avg() { diff --git a/apis/Google.Cloud.Firestore/Google.Cloud.Firestore.IntegrationTests/QueryTest.cs b/apis/Google.Cloud.Firestore/Google.Cloud.Firestore.IntegrationTests/QueryTest.cs index 4e04533a5458..32a4e63e7e0c 100644 --- a/apis/Google.Cloud.Firestore/Google.Cloud.Firestore.IntegrationTests/QueryTest.cs +++ b/apis/Google.Cloud.Firestore/Google.Cloud.Firestore.IntegrationTests/QueryTest.cs @@ -16,6 +16,7 @@ using Google.Cloud.Firestore.IntegrationTests.Models; using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using System.Threading.Tasks; using Xunit; @@ -169,6 +170,30 @@ public async Task StartAfter() Assert.Equal(HighScore.Data.Where(x => x.Level > 20).OrderBy(x => x.Level), items); } + [Fact] + public async Task StartAfter_Explain() + { + var query = _fixture.HighScoreCollection.OrderBy("Level").StartAfter(20); + var plan = await query.ExplainAsync(); + Assert.NotNull(plan); + } + + [Fact] + public async Task StartAfter_ExplainAnalyze() + { + var query = _fixture.HighScoreCollection.OrderBy("Level").StartAfter(20); + var queryProfileInfo = await query.ExplainAnalyzeAsync(); + var snapshot = queryProfileInfo.Snapshot; + var plan = queryProfileInfo.Plan; + var stats = queryProfileInfo.Stats; + Assert.NotNull(plan); + Assert.NotNull(stats); + Assert.Equal(snapshot.Documents.Count, stats.ResultsReturned); + Assert.NotEqual(0, stats.ReadOperations); + var items = snapshot.Documents.Select(doc => doc.ConvertTo()).ToList(); + Assert.Equal(HighScore.Data.Where(x => x.Level > 20).OrderBy(x => x.Level), items); + } + [Fact] public async Task EndAt() { diff --git a/apis/Google.Cloud.Firestore/Google.Cloud.Firestore.sln b/apis/Google.Cloud.Firestore/Google.Cloud.Firestore.sln index 0cbd28d56a8f..5da240c42242 100644 --- a/apis/Google.Cloud.Firestore/Google.Cloud.Firestore.sln +++ b/apis/Google.Cloud.Firestore/Google.Cloud.Firestore.sln @@ -3,59 +3,65 @@ Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.2.32516.85 MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Google.Cloud.Firestore", "Google.Cloud.Firestore\Google.Cloud.Firestore.csproj", "{23BB3FFF-DC4C-F978-04B5-C5E146893E68}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Google.Cloud.Firestore", "Google.Cloud.Firestore\Google.Cloud.Firestore.csproj", "{23BB3FFF-DC4C-F978-04B5-C5E146893E68}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Google.Cloud.Firestore.Benchmarks", "Google.Cloud.Firestore.Benchmarks\Google.Cloud.Firestore.Benchmarks.csproj", "{3B576042-1EF8-EA8B-2554-C5FF11CB3F48}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Google.Cloud.Firestore.Benchmarks", "Google.Cloud.Firestore.Benchmarks\Google.Cloud.Firestore.Benchmarks.csproj", "{3B576042-1EF8-EA8B-2554-C5FF11CB3F48}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Google.Cloud.Firestore.CleanTestData", "Google.Cloud.Firestore.CleanTestData\Google.Cloud.Firestore.CleanTestData.csproj", "{1A7F1C0F-72C8-8983-9906-1EA379969C43}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Google.Cloud.Firestore.CleanTestData", "Google.Cloud.Firestore.CleanTestData\Google.Cloud.Firestore.CleanTestData.csproj", "{1A7F1C0F-72C8-8983-9906-1EA379969C43}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Google.Cloud.Firestore.IntegrationTests", "Google.Cloud.Firestore.IntegrationTests\Google.Cloud.Firestore.IntegrationTests.csproj", "{ECA6C703-9C4B-5DB6-610C-37217893669E}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Google.Cloud.Firestore.IntegrationTests", "Google.Cloud.Firestore.IntegrationTests\Google.Cloud.Firestore.IntegrationTests.csproj", "{ECA6C703-9C4B-5DB6-610C-37217893669E}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Google.Cloud.Firestore.Snippets", "Google.Cloud.Firestore.Snippets\Google.Cloud.Firestore.Snippets.csproj", "{5AB44017-319A-FA5E-4BB7-AEE4663EA6F8}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Google.Cloud.Firestore.Snippets", "Google.Cloud.Firestore.Snippets\Google.Cloud.Firestore.Snippets.csproj", "{5AB44017-319A-FA5E-4BB7-AEE4663EA6F8}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Google.Cloud.Firestore.Tests", "Google.Cloud.Firestore.Tests\Google.Cloud.Firestore.Tests.csproj", "{3023B3E0-2A3A-0DE8-37B9-FC5DEFC655A7}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Google.Cloud.Firestore.Tests", "Google.Cloud.Firestore.Tests\Google.Cloud.Firestore.Tests.csproj", "{3023B3E0-2A3A-0DE8-37B9-FC5DEFC655A7}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Google.Cloud.ClientTesting", "..\..\tools\Google.Cloud.ClientTesting\Google.Cloud.ClientTesting.csproj", "{29974B0C-A7B0-8CA8-AE32-99F622C89044}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Google.Cloud.ClientTesting", "..\..\tools\Google.Cloud.ClientTesting\Google.Cloud.ClientTesting.csproj", "{29974B0C-A7B0-8CA8-AE32-99F622C89044}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Google.Cloud.Firestore.V1", "..\Google.Cloud.Firestore.V1\Google.Cloud.Firestore.V1\Google.Cloud.Firestore.V1.csproj", "{73C26D2D-CC8B-10B3-B6AB-5B25E6859268}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Google.Cloud.Firestore.V1", "..\Google.Cloud.Firestore.V1\Google.Cloud.Firestore.V1\Google.Cloud.Firestore.V1.csproj", "{73C26D2D-CC8B-10B3-B6AB-5B25E6859268}" EndProject Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {23BB3FFF-DC4C-F978-04B5-C5E146893E68}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {23BB3FFF-DC4C-F978-04B5-C5E146893E68}.Debug|Any CPU.Build.0 = Debug|Any CPU - {23BB3FFF-DC4C-F978-04B5-C5E146893E68}.Release|Any CPU.ActiveCfg = Release|Any CPU - {23BB3FFF-DC4C-F978-04B5-C5E146893E68}.Release|Any CPU.Build.0 = Release|Any CPU - {3B576042-1EF8-EA8B-2554-C5FF11CB3F48}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {3B576042-1EF8-EA8B-2554-C5FF11CB3F48}.Debug|Any CPU.Build.0 = Debug|Any CPU - {3B576042-1EF8-EA8B-2554-C5FF11CB3F48}.Release|Any CPU.ActiveCfg = Release|Any CPU - {3B576042-1EF8-EA8B-2554-C5FF11CB3F48}.Release|Any CPU.Build.0 = Release|Any CPU - {1A7F1C0F-72C8-8983-9906-1EA379969C43}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {1A7F1C0F-72C8-8983-9906-1EA379969C43}.Debug|Any CPU.Build.0 = Debug|Any CPU - {1A7F1C0F-72C8-8983-9906-1EA379969C43}.Release|Any CPU.ActiveCfg = Release|Any CPU - {1A7F1C0F-72C8-8983-9906-1EA379969C43}.Release|Any CPU.Build.0 = Release|Any CPU - {ECA6C703-9C4B-5DB6-610C-37217893669E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {ECA6C703-9C4B-5DB6-610C-37217893669E}.Debug|Any CPU.Build.0 = Debug|Any CPU - {ECA6C703-9C4B-5DB6-610C-37217893669E}.Release|Any CPU.ActiveCfg = Release|Any CPU - {ECA6C703-9C4B-5DB6-610C-37217893669E}.Release|Any CPU.Build.0 = Release|Any CPU - {5AB44017-319A-FA5E-4BB7-AEE4663EA6F8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {5AB44017-319A-FA5E-4BB7-AEE4663EA6F8}.Debug|Any CPU.Build.0 = Debug|Any CPU - {5AB44017-319A-FA5E-4BB7-AEE4663EA6F8}.Release|Any CPU.ActiveCfg = Release|Any CPU - {5AB44017-319A-FA5E-4BB7-AEE4663EA6F8}.Release|Any CPU.Build.0 = Release|Any CPU - {3023B3E0-2A3A-0DE8-37B9-FC5DEFC655A7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {3023B3E0-2A3A-0DE8-37B9-FC5DEFC655A7}.Debug|Any CPU.Build.0 = Debug|Any CPU - {3023B3E0-2A3A-0DE8-37B9-FC5DEFC655A7}.Release|Any CPU.ActiveCfg = Release|Any CPU - {3023B3E0-2A3A-0DE8-37B9-FC5DEFC655A7}.Release|Any CPU.Build.0 = Release|Any CPU - {29974B0C-A7B0-8CA8-AE32-99F622C89044}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {29974B0C-A7B0-8CA8-AE32-99F622C89044}.Debug|Any CPU.Build.0 = Debug|Any CPU - {29974B0C-A7B0-8CA8-AE32-99F622C89044}.Release|Any CPU.ActiveCfg = Release|Any CPU - {29974B0C-A7B0-8CA8-AE32-99F622C89044}.Release|Any CPU.Build.0 = Release|Any CPU - {73C26D2D-CC8B-10B3-B6AB-5B25E6859268}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {73C26D2D-CC8B-10B3-B6AB-5B25E6859268}.Debug|Any CPU.Build.0 = Debug|Any CPU - {73C26D2D-CC8B-10B3-B6AB-5B25E6859268}.Release|Any CPU.ActiveCfg = Release|Any CPU - {73C26D2D-CC8B-10B3-B6AB-5B25E6859268}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {23BB3FFF-DC4C-F978-04B5-C5E146893E68}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {23BB3FFF-DC4C-F978-04B5-C5E146893E68}.Debug|Any CPU.Build.0 = Debug|Any CPU + {23BB3FFF-DC4C-F978-04B5-C5E146893E68}.Release|Any CPU.ActiveCfg = Release|Any CPU + {23BB3FFF-DC4C-F978-04B5-C5E146893E68}.Release|Any CPU.Build.0 = Release|Any CPU + {3B576042-1EF8-EA8B-2554-C5FF11CB3F48}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3B576042-1EF8-EA8B-2554-C5FF11CB3F48}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3B576042-1EF8-EA8B-2554-C5FF11CB3F48}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3B576042-1EF8-EA8B-2554-C5FF11CB3F48}.Release|Any CPU.Build.0 = Release|Any CPU + {1A7F1C0F-72C8-8983-9906-1EA379969C43}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1A7F1C0F-72C8-8983-9906-1EA379969C43}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1A7F1C0F-72C8-8983-9906-1EA379969C43}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1A7F1C0F-72C8-8983-9906-1EA379969C43}.Release|Any CPU.Build.0 = Release|Any CPU + {ECA6C703-9C4B-5DB6-610C-37217893669E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {ECA6C703-9C4B-5DB6-610C-37217893669E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {ECA6C703-9C4B-5DB6-610C-37217893669E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {ECA6C703-9C4B-5DB6-610C-37217893669E}.Release|Any CPU.Build.0 = Release|Any CPU + {5AB44017-319A-FA5E-4BB7-AEE4663EA6F8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5AB44017-319A-FA5E-4BB7-AEE4663EA6F8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5AB44017-319A-FA5E-4BB7-AEE4663EA6F8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5AB44017-319A-FA5E-4BB7-AEE4663EA6F8}.Release|Any CPU.Build.0 = Release|Any CPU + {3023B3E0-2A3A-0DE8-37B9-FC5DEFC655A7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3023B3E0-2A3A-0DE8-37B9-FC5DEFC655A7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3023B3E0-2A3A-0DE8-37B9-FC5DEFC655A7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3023B3E0-2A3A-0DE8-37B9-FC5DEFC655A7}.Release|Any CPU.Build.0 = Release|Any CPU + {29974B0C-A7B0-8CA8-AE32-99F622C89044}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {29974B0C-A7B0-8CA8-AE32-99F622C89044}.Debug|Any CPU.Build.0 = Debug|Any CPU + {29974B0C-A7B0-8CA8-AE32-99F622C89044}.Release|Any CPU.ActiveCfg = Release|Any CPU + {29974B0C-A7B0-8CA8-AE32-99F622C89044}.Release|Any CPU.Build.0 = Release|Any CPU + {73C26D2D-CC8B-10B3-B6AB-5B25E6859268}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {73C26D2D-CC8B-10B3-B6AB-5B25E6859268}.Debug|Any CPU.Build.0 = Debug|Any CPU + {73C26D2D-CC8B-10B3-B6AB-5B25E6859268}.Release|Any CPU.ActiveCfg = Release|Any CPU + {73C26D2D-CC8B-10B3-B6AB-5B25E6859268}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {46F695E8-04B1-4C09-9C59-1101A63C5BE0} + EndGlobalSection EndGlobal diff --git a/apis/Google.Cloud.Firestore/Google.Cloud.Firestore/AggregateQuery.cs b/apis/Google.Cloud.Firestore/Google.Cloud.Firestore/AggregateQuery.cs index 82c712bfb714..965bf0bee68f 100644 --- a/apis/Google.Cloud.Firestore/Google.Cloud.Firestore/AggregateQuery.cs +++ b/apis/Google.Cloud.Firestore/Google.Cloud.Firestore/AggregateQuery.cs @@ -12,6 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. +using Google.Api; using Google.Api.Gax; using Google.Api.Gax.Grpc; using Google.Cloud.Firestore.V1; @@ -58,10 +59,35 @@ internal AggregateQuery(Query query, IReadOnlyList aggregateFiel public Task GetSnapshotAsync(CancellationToken cancellationToken = default) => GetSnapshotAsync(null, cancellationToken); + /// + /// + /// + /// + /// + public async Task ExplainAsync(CancellationToken cancellationToken = default) + { + var profileSnapshot = await GetProfileSnapshotAsync(transactionId: null, explainOptions: new ExplainOptions { Analyze = false }, cancellationToken).ConfigureAwait(false); + return profileSnapshot.Plan; + } + + /// + /// + /// + /// + /// + public Task> ExplainAnalyzeAsync(CancellationToken cancellationToken = default) => + GetProfileSnapshotAsync(transactionId: null, explainOptions: new ExplainOptions { Analyze = true }, cancellationToken); + internal async Task GetSnapshotAsync(ByteString transactionId, CancellationToken cancellationToken) + { + var profileSnapshot = await GetProfileSnapshotAsync(transactionId, explainOptions: null, cancellationToken).ConfigureAwait(false); + return profileSnapshot.Snapshot; + } + + internal async Task> GetProfileSnapshotAsync(ByteString transactionId, ExplainOptions explainOptions, CancellationToken cancellationToken) { var query = ToStructuredAggregationQuery(); - IAsyncEnumerable responseStream = GetAggregationQueryResponseStreamAsync(transactionId, cancellationToken); + IAsyncEnumerable responseStream = GetAggregationQueryResponseStreamAsync(transactionId, explainOptions, cancellationToken); Timestamp? readTime = null; // This is a map from the user-specified alias to the resulting value. @@ -76,14 +102,18 @@ internal async Task GetSnapshotAsync(ByteString transact var aggregate = _aggregateFields[i]; queryAliasToUserAlias[aggregate.GetAliasForIndex(i)] = aggregate.Alias; } - + ExplainMetrics metrics = null; await responseStream.ForEachAsync(ProcessResponse, cancellationToken).ConfigureAwait(false); - GaxPreconditions.CheckState(readTime != null, "The stream returned from RunAggregationQuery did not provide a read timestamp."); - return new AggregateQuerySnapshot(this, readTime.Value, data); + GaxPreconditions.CheckState(readTime is not null || explainOptions?.Analyze == false, "The stream returned from RunRunAggregationQueryQuery did not provide a read timestamp."); + GaxPreconditions.CheckState(explainOptions is null || metrics is not null, "The stream returned from RunAggregationQuery did not provide metrics."); + + // We rely on AggregateQuerySnapshot.ReadTime not being accessed when we're just doing an explain operation. + var snapshot = new AggregateQuerySnapshot(this, readTime, data); + return new QueryProfileInfo(snapshot, metrics); void ProcessResponse(RunAggregationQueryResponse response) { - if (response.Result.AggregateFields is { } aggregateFields) + if (response.Result?.AggregateFields is { } aggregateFields) { foreach (var pair in aggregateFields) { @@ -94,6 +124,7 @@ void ProcessResponse(RunAggregationQueryResponse response) } } readTime ??= Timestamp.FromProtoOrNull(response.ReadTime); + metrics = response.ExplainMetrics; } } @@ -101,12 +132,13 @@ void ProcessResponse(RunAggregationQueryResponse response) // from GetSnapshotAsync which could ensure it disposes of the response. However, it's simplest // to keep this implementation in common with Query.StreamResponsesAsync, which effectively // needs to use an iterator block so we can return an IAsyncEnumerable from Query.StreamAsync. - private async IAsyncEnumerable GetAggregationQueryResponseStreamAsync(ByteString transactionId, [EnumeratorCancellation] CancellationToken cancellationToken) + private async IAsyncEnumerable GetAggregationQueryResponseStreamAsync(ByteString transactionId, ExplainOptions explainOptions, [EnumeratorCancellation] CancellationToken cancellationToken) { RunAggregationQueryRequest request = new RunAggregationQueryRequest { Parent = _query.ParentPath, - StructuredAggregationQuery = ToStructuredAggregationQuery() + StructuredAggregationQuery = ToStructuredAggregationQuery(), + ExplainOptions = explainOptions }; if (transactionId != null) { diff --git a/apis/Google.Cloud.Firestore/Google.Cloud.Firestore/AggregateQuerySnapshot.cs b/apis/Google.Cloud.Firestore/Google.Cloud.Firestore/AggregateQuerySnapshot.cs index 59f6b567bb69..d5afb77e5b9a 100644 --- a/apis/Google.Cloud.Firestore/Google.Cloud.Firestore/AggregateQuerySnapshot.cs +++ b/apis/Google.Cloud.Firestore/Google.Cloud.Firestore/AggregateQuerySnapshot.cs @@ -28,6 +28,7 @@ public sealed class AggregateQuerySnapshot : IEquatable, /// The data within the snapshot. This is a MapField as that provides equality and hashing out of the box. /// private readonly MapField _data; + private readonly Timestamp? _readTime; /// /// The query producing this snapshot. @@ -37,7 +38,7 @@ public sealed class AggregateQuerySnapshot : IEquatable, /// /// The time at which the snapshot was read. /// - public Timestamp ReadTime { get; } + public Timestamp ReadTime => _readTime ?? throw new InvalidOperationException("No read time available"); /// /// Number of documents that matches the query. May be null when count aggregation is not applied on the Query. @@ -47,10 +48,10 @@ public sealed class AggregateQuerySnapshot : IEquatable, value.ValueTypeCase == Value.ValueTypeOneofCase.IntegerValue ? value.IntegerValue : null; - internal AggregateQuerySnapshot(AggregateQuery query, Timestamp readTime, MapField data) + internal AggregateQuerySnapshot(AggregateQuery query, Timestamp? readTime, MapField data) { Query = query; - ReadTime = readTime; + _readTime = readTime; _data = data; } diff --git a/apis/Google.Cloud.Firestore/Google.Cloud.Firestore/Query.cs b/apis/Google.Cloud.Firestore/Google.Cloud.Firestore/Query.cs index f55b1aa935d3..b7bc0bfc11d5 100644 --- a/apis/Google.Cloud.Firestore/Google.Cloud.Firestore/Query.cs +++ b/apis/Google.Cloud.Firestore/Google.Cloud.Firestore/Query.cs @@ -703,11 +703,37 @@ public Query Offset(int offset) /// A snapshot of documents matching the query. public Task GetSnapshotAsync(CancellationToken cancellationToken = default) => GetSnapshotAsync(null, cancellationToken); + /// + /// + /// + /// + /// + public async Task ExplainAsync(CancellationToken cancellationToken = default) + { + var profileSnapshot = await GetProfileSnapshotAsync(transactionId: null, explainOptions: new ExplainOptions { Analyze = false }, cancellationToken).ConfigureAwait(false); + return profileSnapshot.Plan; + } + + /// + /// + /// + /// + /// + public Task> ExplainAnalyzeAsync(CancellationToken cancellationToken = default) => + GetProfileSnapshotAsync(transactionId: null, explainOptions: new ExplainOptions { Analyze = true }, cancellationToken); + internal async Task GetSnapshotAsync(ByteString transactionId, CancellationToken cancellationToken) { - var responses = StreamResponsesAsync(transactionId, cancellationToken, allowLimitToLast: true); + var profileSnapshot = await GetProfileSnapshotAsync(transactionId, explainOptions: null, cancellationToken).ConfigureAwait(false); + return profileSnapshot.Snapshot; + } + + private async Task> GetProfileSnapshotAsync(ByteString transactionId, ExplainOptions explainOptions, CancellationToken cancellationToken) + { + var responses = StreamResponsesAsync(transactionId, explainOptions, cancellationToken, allowLimitToLast: true); Timestamp? readTime = null; List snapshots = new List(); + ExplainMetrics metrics = null; await responses.ForEachAsync(response => { if (response.Document != null) @@ -718,16 +744,21 @@ await responses.ForEachAsync(response => { readTime = Timestamp.FromProto(response.ReadTime); } + // This will be set on the last response, so we can always just remember "just the last value we saw". + metrics = response.ExplainMetrics; }, cancellationToken).ConfigureAwait(false); - GaxPreconditions.CheckState(readTime != null, "The stream returned from RunQuery did not provide a read timestamp."); + GaxPreconditions.CheckState(readTime is not null || explainOptions?.Analyze == false, "The stream returned from RunQuery did not provide a read timestamp."); + GaxPreconditions.CheckState(explainOptions is null || metrics is not null, "The stream returned from RunQuery did not provide metrics."); if (IsLimitToLast) { // Reverse in-place. We *could* create an IReadOnlyList which acted as a "reversing view" // but that seems like unnecessary work for now. snapshots.Reverse(); } - return QuerySnapshot.ForDocuments(this, snapshots.AsReadOnly(), readTime.Value); + // We rely on QuerySnapshot.ReadTime not being accessed when we're just doing an explain operation. + var snapshot = QuerySnapshot.ForDocuments(this, snapshots.AsReadOnly(), readTime); + return new QueryProfileInfo(snapshot, metrics); } /// @@ -753,20 +784,20 @@ public IAsyncEnumerable StreamAsync(CancellationToken cancella StreamAsync(transactionId: null, cancellationToken, false); internal IAsyncEnumerable StreamAsync(ByteString transactionId, CancellationToken cancellationToken, bool allowLimitToLast) => - StreamResponsesAsync(transactionId, cancellationToken, allowLimitToLast) + StreamResponsesAsync(transactionId, null, cancellationToken, allowLimitToLast) .Where(resp => resp.Document != null) .Select(resp => DocumentSnapshot.ForDocument(Database, resp.Document, Timestamp.FromProto(resp.ReadTime))); // Implementation note: this uses an iterator block so that we can dispose of the gRPC call // appropriately. The code will only execute when GetEnumerator() is called on the returned value, // so the gRPC call *will* be disposed so long as the caller disposes of the iterator (or completes it). - private async IAsyncEnumerable StreamResponsesAsync(ByteString transactionId, [EnumeratorCancellation] CancellationToken cancellationToken, bool allowLimitToLast) + private async IAsyncEnumerable StreamResponsesAsync(ByteString transactionId, ExplainOptions explainOptions, [EnumeratorCancellation] CancellationToken cancellationToken, bool allowLimitToLast) { if (IsLimitToLast && !allowLimitToLast) { throw new InvalidOperationException($"Cannot stream responses for query using {nameof(LimitToLast)}"); } - var request = new RunQueryRequest { StructuredQuery = ToStructuredQuery(), Parent = ParentPath }; + var request = new RunQueryRequest { StructuredQuery = ToStructuredQuery(), Parent = ParentPath, ExplainOptions = explainOptions }; if (transactionId != null) { request.Transaction = transactionId; diff --git a/apis/Google.Cloud.Firestore/Google.Cloud.Firestore/QueryProfileInfo.cs b/apis/Google.Cloud.Firestore/Google.Cloud.Firestore/QueryProfileInfo.cs new file mode 100644 index 000000000000..f72511f4b329 --- /dev/null +++ b/apis/Google.Cloud.Firestore/Google.Cloud.Firestore/QueryProfileInfo.cs @@ -0,0 +1,48 @@ +// Copyright 2024 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"): +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://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. + +using Google.Cloud.Firestore.V1; + +namespace Google.Cloud.Firestore; + +/// +/// +/// +/// +public sealed class QueryProfileInfo +{ + /// + /// + /// + public T Snapshot { get; } + + // TODO: Expose an IReadOnlyDictionary instead? + /// + /// The query plan that was executed or profiled. + /// + public PlanSummary Plan { get; } + + // TODO: Expose an IReadOnlyDictionary instead? + /// + /// The stats for the query, or null if the query was only profiled. + /// + public ExecutionStats Stats { get; } + + internal QueryProfileInfo(T snapshot, ExplainMetrics metrics) + { + Snapshot = snapshot; + Plan = metrics?.PlanSummary; + Stats = metrics?.ExecutionStats; + } +} diff --git a/apis/Google.Cloud.Firestore/Google.Cloud.Firestore/QuerySnapshot.cs b/apis/Google.Cloud.Firestore/Google.Cloud.Firestore/QuerySnapshot.cs index 2de0ced88e68..f795710f397a 100644 --- a/apis/Google.Cloud.Firestore/Google.Cloud.Firestore/QuerySnapshot.cs +++ b/apis/Google.Cloud.Firestore/Google.Cloud.Firestore/QuerySnapshot.cs @@ -28,16 +28,17 @@ public sealed class QuerySnapshot : IReadOnlyList, IEquatable< { private readonly Lazy> _lazyDocumentList; private readonly Lazy> _lazyChangeList; + private readonly Timestamp? _readTime; - private QuerySnapshot(Query query, Func> documentProvider, Func> changesProvider, Timestamp readTime) + private QuerySnapshot(Query query, Func> documentProvider, Func> changesProvider, Timestamp? readTime) { Query = query; _lazyDocumentList = new Lazy>(documentProvider, LazyThreadSafetyMode.ExecutionAndPublication); _lazyChangeList = new Lazy>(changesProvider, LazyThreadSafetyMode.ExecutionAndPublication); - ReadTime = readTime; + _readTime = readTime; } - internal static QuerySnapshot ForDocuments(Query query, IReadOnlyList documents, Timestamp readTime) => + internal static QuerySnapshot ForDocuments(Query query, IReadOnlyList documents, Timestamp? readTime) => new QuerySnapshot(query, () => documents, () => new LazyChangeList(documents), readTime); internal static QuerySnapshot ForChanges(Query query, IEnumerable documentSet, IReadOnlyList changes, Timestamp readTime) => @@ -51,7 +52,7 @@ internal static QuerySnapshot ForChanges(Query query, IEnumerable /// The time at which the snapshot was read. /// - public Timestamp ReadTime { get; } + public Timestamp ReadTime => _readTime ?? throw new InvalidOperationException("No read time available"); /// /// The documents in the snapshot.