Skip to content

Commit 898ea1d

Browse files
authored
feat: add getQuery method to Firestore transactions for transaction… (#113)
* feat: add `getQuery` method to Firestore transactions for transactional query execution. * chore: revert version and changelog per reviewer feedback * fix: use FirestoreException instead of generic Exception for read-after-write error
1 parent 9139b1f commit 898ea1d

File tree

4 files changed

+182
-4
lines changed

4 files changed

+182
-4
lines changed

packages/googleapis_firestore/lib/src/firestore.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ part 'firestore_exception.dart';
2828
part 'firestore_http_client.dart';
2929
part 'geo_point.dart';
3030
part 'path.dart';
31+
part 'query_reader.dart';
3132
part 'reference/aggregate_query.dart';
3233
part 'reference/aggregate_query_snapshot.dart';
3334
part 'reference/collection_reference.dart';
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
part of 'firestore.dart';
2+
3+
/// Response wrapper containing both query results and transaction ID.
4+
class _QueryReaderResponse<T> {
5+
_QueryReaderResponse(this.result, this.transaction);
6+
7+
final QuerySnapshot<T> result;
8+
final String? transaction;
9+
}
10+
11+
/// Reader class for executing queries within transactions.
12+
///
13+
/// Follows the same pattern as [_DocumentReader] to handle:
14+
/// - Lazy transaction initialization via `transactionOptions`
15+
/// - Reusing existing transactions via `transactionId`
16+
/// - Read-only snapshots via `readTime`
17+
/// - Capturing and returning transaction IDs from responses
18+
class _QueryReader<T> {
19+
_QueryReader({
20+
required this.query,
21+
this.transactionId,
22+
this.readTime,
23+
this.transactionOptions,
24+
}) : assert(
25+
[transactionId, readTime, transactionOptions].nonNulls.length <= 1,
26+
'Only transactionId or readTime or transactionOptions must be provided. '
27+
'transactionId = $transactionId, readTime = $readTime, transactionOptions = $transactionOptions',
28+
);
29+
30+
final Query<T> query;
31+
final String? transactionId;
32+
final Timestamp? readTime;
33+
final firestore_v1.TransactionOptions? transactionOptions;
34+
35+
String? _retrievedTransactionId;
36+
37+
/// Executes the query and captures the transaction ID from the response stream.
38+
///
39+
/// Returns a [_QueryReaderResponse] containing both the query results and
40+
/// the transaction ID (if one was started or provided).
41+
Future<_QueryReaderResponse<T>> _get() async {
42+
final request = query._toProto(
43+
transactionId: transactionId,
44+
readTime: readTime,
45+
transactionOptions: transactionOptions,
46+
);
47+
48+
final response = await query.firestore._firestoreClient.v1((
49+
api,
50+
projectId,
51+
) async {
52+
return api.projects.databases.documents.runQuery(
53+
request,
54+
query._buildProtoParentPath(),
55+
);
56+
});
57+
58+
Timestamp? queryReadTime;
59+
final snapshots = <QueryDocumentSnapshot<T>>[];
60+
61+
// Process streaming response
62+
for (final e in response) {
63+
// Capture transaction ID from response (if present)
64+
if (e.transaction?.isNotEmpty ?? false) {
65+
_retrievedTransactionId = e.transaction;
66+
}
67+
68+
final document = e.document;
69+
if (document == null) {
70+
// End of stream marker
71+
queryReadTime = e.readTime.let(Timestamp._fromString);
72+
continue;
73+
}
74+
75+
// Convert proto document to DocumentSnapshot
76+
final snapshot = DocumentSnapshot._fromDocument(
77+
document,
78+
e.readTime,
79+
query.firestore,
80+
);
81+
82+
// Recreate with proper converter
83+
final finalDoc =
84+
_DocumentSnapshotBuilder(
85+
snapshot.ref.withConverter<T>(
86+
fromFirestore: query._queryOptions.converter.fromFirestore,
87+
toFirestore: query._queryOptions.converter.toFirestore,
88+
),
89+
)
90+
..fieldsProto = firestore_v1.MapValue(fields: document.fields)
91+
..readTime = snapshot.readTime
92+
..createTime = snapshot.createTime
93+
..updateTime = snapshot.updateTime;
94+
95+
snapshots.add(finalDoc.build() as QueryDocumentSnapshot<T>);
96+
}
97+
98+
// Return both query results and transaction ID
99+
return _QueryReaderResponse<T>(
100+
QuerySnapshot<T>._(
101+
query: query,
102+
readTime: queryReadTime,
103+
docs: snapshots,
104+
),
105+
_retrievedTransactionId,
106+
);
107+
}
108+
}

packages/googleapis_firestore/lib/src/reference/query.dart

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -428,9 +428,20 @@ base class Query<T> {
428428
firestore_v1.RunQueryRequest _toProto({
429429
required String? transactionId,
430430
required Timestamp? readTime,
431+
firestore_v1.TransactionOptions? transactionOptions,
431432
}) {
432-
if (readTime != null && transactionId != null) {
433-
throw ArgumentError('readTime and transactionId cannot both be set.');
433+
// Validate mutual exclusivity of transaction parameters
434+
final providedParams = [
435+
transactionId,
436+
readTime,
437+
transactionOptions,
438+
].nonNulls.length;
439+
440+
if (providedParams > 1) {
441+
throw ArgumentError(
442+
'Only one of transactionId, readTime, or transactionOptions can be specified. '
443+
'Got: transactionId=$transactionId, readTime=$readTime, transactionOptions=$transactionOptions',
444+
);
434445
}
435446

436447
final structuredQuery = _toStructuredQuery();
@@ -482,6 +493,8 @@ base class Query<T> {
482493
runQueryRequest.transaction = transactionId;
483494
} else if (readTime != null) {
484495
runQueryRequest.readTime = readTime._toProto().timestampValue;
496+
} else if (transactionOptions != null) {
497+
runQueryRequest.newTransaction = transactionOptions;
485498
}
486499

487500
return runQueryRequest;

packages/googleapis_firestore/lib/src/transaction.dart

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,8 +73,6 @@ class Transaction {
7373
Future<String>? _transactionIdPromise;
7474
String? _prevTransactionId;
7575

76-
// TODO support Query as parameter for [get]
77-
7876
/// Retrieves a single document from the database. Holds a pessimistic lock on
7977
/// the returned document.
8078
///
@@ -98,6 +96,43 @@ class Transaction {
9896
>(docRef, resultFn: _getSingleFn);
9997
}
10098

99+
/// Executes a query and returns the results. Holds a pessimistic lock on
100+
/// all documents in the result set.
101+
///
102+
/// - [query]: The query to execute.
103+
///
104+
/// Returns a [QuerySnapshot] containing the query results.
105+
///
106+
/// All documents matched by the query will be locked for the duration of
107+
/// the transaction. The query is executed at a consistent snapshot, ensuring
108+
/// that all reads see the same data.
109+
///
110+
/// ```dart
111+
/// firestore.runTransaction((transaction) async {
112+
/// final query = firestore.collection('users')
113+
/// .where('active', WhereFilter.equal, true)
114+
/// .limit(100);
115+
///
116+
/// final snapshot = await transaction.getQuery(query);
117+
///
118+
/// for (final doc in snapshot.docs) {
119+
/// transaction.update(doc.ref, {'processed': true});
120+
/// }
121+
/// });
122+
/// ```
123+
Future<QuerySnapshot<T>> getQuery<T>(Query<T> query) async {
124+
if (_writeBatch != null && _writeBatch._operations.isNotEmpty) {
125+
throw FirestoreException(
126+
FirestoreClientErrorCode.failedPrecondition,
127+
readAfterWriteErrorMsg,
128+
);
129+
}
130+
return _withLazyStartedTransaction<Query<T>, QuerySnapshot<T>>(
131+
query,
132+
resultFn: _getQueryFn,
133+
);
134+
}
135+
101136
/// Retrieve multiple documents from the database by the provided
102137
/// [documentsRefs]. Holds a pessimistic lock on all returned documents.
103138
/// If any of the documents do not exist, the operation throws a
@@ -387,6 +422,27 @@ class Transaction {
387422
);
388423
}
389424

425+
Future<_TransactionResult<QuerySnapshot<T>>> _getQueryFn<T>(
426+
Query<T> query, {
427+
String? transactionId,
428+
Timestamp? readTime,
429+
firestore_v1.TransactionOptions? transactionOptions,
430+
List<FieldPath>? fieldMask,
431+
}) async {
432+
final reader = _QueryReader(
433+
query: query,
434+
transactionId: transactionId,
435+
readTime: readTime,
436+
transactionOptions: transactionOptions,
437+
);
438+
439+
final result = await reader._get();
440+
return _TransactionResult(
441+
transaction: result.transaction,
442+
result: result.result,
443+
);
444+
}
445+
390446
Future<T> _runTransaction<T>(TransactionHandler<T> updateFunction) async {
391447
// No backoff is set for readonly transactions (i.e. attempts == 1)
392448
if (_writeBatch == null) {

0 commit comments

Comments
 (0)