Skip to content

Commit bd2bb87

Browse files
committed
feat: add getQuery method to Firestore transactions for transactional query execution.
1 parent 4a4fc49 commit bd2bb87

File tree

8 files changed

+479
-6
lines changed

8 files changed

+479
-6
lines changed

melos.yaml

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,14 @@
11
name: dart_firebase_admin
22

33
packages:
4-
- "packages/**"
4+
- "packages/**"
5+
6+
scripts:
7+
get:
8+
run: melos exec -- "flutter pub get"
9+
description: Run `flutter pub get` for all packages
10+
11+
analyze:
12+
run: dart analyze
13+
test:
14+
run: dart test

packages/dart_firebase_admin/CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,10 @@
1+
## 0.4.2 - 2025-12-13
2+
3+
- **FEAT**: Added `Transaction.getQuery()` method to enable query execution within transactions with pessimistic locking
4+
- **FEAT**: Queries can now participate in Firestore transactions with full ACID guarantees
5+
- **FEAT**: Added support for lazy transaction initialization with queries
6+
- **TEST**: Added 10 comprehensive tests for query transaction scenarios
7+
18
## 0.4.1 - 2025-03-21
29

310
- Bump intl to `0.20.0`

packages/dart_firebase_admin/lib/src/google_cloud_firestore/firestore.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ part 'firestore_api_request_internal.dart';
2626
part 'firestore_exception.dart';
2727
part 'geo_point.dart';
2828
part 'path.dart';
29+
part 'query_reader.dart';
2930
part 'reference.dart';
3031
part 'serializer.dart';
3132
part 'timestamp.dart';
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
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 firestore1.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._client.v1((client) async {
49+
return client.projects.databases.documents.runQuery(
50+
request,
51+
query._buildProtoParentPath(),
52+
);
53+
});
54+
55+
Timestamp? queryReadTime;
56+
final snapshots = <QueryDocumentSnapshot<T>>[];
57+
58+
// Process streaming response
59+
for (final e in response) {
60+
// Capture transaction ID from response (if present)
61+
if (e.transaction?.isNotEmpty ?? false) {
62+
_retrievedTransactionId = e.transaction;
63+
}
64+
65+
final document = e.document;
66+
if (document == null) {
67+
// End of stream marker
68+
queryReadTime = e.readTime.let(Timestamp._fromString);
69+
continue;
70+
}
71+
72+
// Convert proto document to DocumentSnapshot
73+
final snapshot = DocumentSnapshot._fromDocument(
74+
document,
75+
e.readTime,
76+
query.firestore,
77+
);
78+
79+
// Recreate with proper converter
80+
final finalDoc = _DocumentSnapshotBuilder(
81+
snapshot.ref.withConverter<T>(
82+
fromFirestore: query._queryOptions.converter.fromFirestore,
83+
toFirestore: query._queryOptions.converter.toFirestore,
84+
),
85+
)
86+
..fieldsProto = firestore1.MapValue(fields: document.fields)
87+
..readTime = snapshot.readTime
88+
..createTime = snapshot.createTime
89+
..updateTime = snapshot.updateTime;
90+
91+
snapshots.add(finalDoc.build() as QueryDocumentSnapshot<T>);
92+
}
93+
94+
// Return both query results and transaction ID
95+
return _QueryReaderResponse<T>(
96+
QuerySnapshot<T>._(
97+
query: query,
98+
readTime: queryReadTime,
99+
docs: snapshots,
100+
),
101+
_retrievedTransactionId,
102+
);
103+
}
104+
}

packages/dart_firebase_admin/lib/src/google_cloud_firestore/reference.dart

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1199,10 +1199,12 @@ base class Query<T> {
11991199
firestore1.RunQueryRequest _toProto({
12001200
required String? transactionId,
12011201
required Timestamp? readTime,
1202+
firestore1.TransactionOptions? transactionOptions,
12021203
}) {
1203-
if (readTime != null && transactionId != null) {
1204+
// Validate that only one of transactionId, readTime, or transactionOptions is set
1205+
if ([transactionId, readTime, transactionOptions].nonNulls.length > 1) {
12041206
throw ArgumentError(
1205-
'readTime and transactionId cannot both be set.',
1207+
'Only one of transactionId, readTime, or transactionOptions can be set.',
12061208
);
12071209
}
12081210

@@ -1253,6 +1255,8 @@ base class Query<T> {
12531255
runQueryRequest.transaction = transactionId;
12541256
} else if (readTime != null) {
12551257
runQueryRequest.readTime = readTime._toProto().timestampValue;
1258+
} else if (transactionOptions != null) {
1259+
runQueryRequest.newTransaction = transactionOptions;
12561260
}
12571261

12581262
return runQueryRequest;

packages/dart_firebase_admin/lib/src/google_cloud_firestore/transaction.dart

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,8 +76,6 @@ class Transaction {
7676
Future<String>? _transactionIdPromise;
7777
String? _prevTransactionId;
7878

79-
// TODO support Query as parameter for [get]
80-
8179
/// Retrieves a single document from the database. Holds a pessimistic lock on
8280
/// the returned document.
8381
///
@@ -101,6 +99,40 @@ class Transaction {
10199
);
102100
}
103101

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

412+
Future<_TransactionResult<QuerySnapshot<T>>> _getQueryFn<T>(
413+
Query<T> query, {
414+
String? transactionId,
415+
Timestamp? readTime,
416+
firestore1.TransactionOptions? transactionOptions,
417+
List<FieldPath>? fieldMask,
418+
}) async {
419+
final reader = _QueryReader(
420+
query: query,
421+
transactionId: transactionId,
422+
readTime: readTime,
423+
transactionOptions: transactionOptions,
424+
);
425+
426+
final result = await reader._get();
427+
return _TransactionResult(
428+
transaction: result.transaction,
429+
result: result.result,
430+
);
431+
}
432+
380433
Future<T> _runTransaction<T>(
381434
TransactionHandler<T> updateFunction,
382435
) async {

packages/dart_firebase_admin/pubspec.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
name: dart_firebase_admin
22
description: A Firebase Admin SDK implementation for Dart.
3-
version: 0.4.1
3+
version: 0.4.2
44
homepage: "https://github.com/invertase/dart_firebase_admin"
55
repository: "https://github.com/invertase/dart_firebase_admin"
66

0 commit comments

Comments
 (0)