Skip to content

Commit 844ce3e

Browse files
authored
feat: implement special handling for IEEE 754 double values in Firestore API (#237)
1 parent 6bcd2b1 commit 844ce3e

3 files changed

Lines changed: 268 additions & 10 deletions

File tree

packages/google_cloud_firestore/lib/src/firestore_http_client.dart

Lines changed: 97 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
// limitations under the License.
1414

1515
import 'dart:async';
16+
import 'dart:convert';
1617

1718
import 'package:google_cloud/constants.dart' as google_cloud;
1819
import 'package:google_cloud/google_cloud.dart' as google_cloud;
@@ -23,8 +24,89 @@ import 'package:meta/meta.dart';
2324

2425
import '../google_cloud_firestore.dart';
2526
import 'environment.dart';
27+
import 'firestore.dart'
28+
show Serializer, kInfinitySentinel, kNaNSentinel, kNegInfinitySentinel;
2629
import 'firestore_exception.dart';
2730

31+
// Matches a complete Firestore Value JSON object whose doubleValue is one of
32+
// the three special IEEE 754 strings the REST API emits. Anchoring on the
33+
// surrounding braces prevents false-positive matches inside user string fields
34+
// that happen to contain the text `"doubleValue":"Infinity"`.
35+
final _specialDoublePattern = RegExp(
36+
r'\{\s*"doubleValue"\s*:\s*"(Infinity|-Infinity|NaN)"\s*\}',
37+
);
38+
39+
/// HTTP client wrapper that rewrites special IEEE 754 double values in
40+
/// Firestore REST API responses before the googleapis library parses them.
41+
///
42+
/// The Firestore REST API encodes [double.infinity], [double.negativeInfinity],
43+
/// and [double.nan] as the JSON strings `"Infinity"`, `"-Infinity"`, and
44+
/// `"NaN"` respectively (since those values are not representable in standard
45+
/// JSON). The googleapis-generated [firestore_v1.Value.fromJson] does a hard `as num` cast
46+
/// on the `doubleValue` field and therefore throws when it encounters a string.
47+
///
48+
/// This client intercepts every response body and replaces those patterns with
49+
/// a `stringValue` sentinel that [Serializer.decodeValue] understands.
50+
/// The sentinel strings are defined alongside [Serializer.decodeValue] in
51+
/// `serializer.dart`.
52+
class _SpecialDoubleClient extends BaseClient {
53+
_SpecialDoubleClient(this._inner);
54+
55+
final Client _inner;
56+
57+
@override
58+
Future<StreamedResponse> send(BaseRequest request) async {
59+
final response = await _inner.send(request);
60+
61+
final contentType = response.headers['content-type'] ?? '';
62+
if (!contentType.contains('application/json')) return response;
63+
64+
final body = await response.stream.bytesToString();
65+
if (!body.contains('"doubleValue"')) return _rebuild(response, body);
66+
67+
final patched = body.replaceAllMapped(_specialDoublePattern, (m) {
68+
final sentinel = switch (m.group(1)) {
69+
'Infinity' => kInfinitySentinel,
70+
'-Infinity' => kNegInfinitySentinel,
71+
_ => kNaNSentinel, // 'NaN'
72+
};
73+
return '{"stringValue":"$sentinel"}';
74+
});
75+
76+
return _rebuild(response, patched);
77+
}
78+
79+
StreamedResponse _rebuild(StreamedResponse original, String body) {
80+
final bytes = utf8.encode(body);
81+
final headers = Map<String, String>.from(original.headers)
82+
..['content-length'] = bytes.length.toString();
83+
return StreamedResponse(
84+
Stream.value(bytes),
85+
original.statusCode,
86+
contentLength: bytes.length,
87+
headers: headers,
88+
request: original.request,
89+
reasonPhrase: original.reasonPhrase,
90+
);
91+
}
92+
}
93+
94+
/// An [googleapis_auth.AuthClient] wrapper that applies [_SpecialDoubleClient]
95+
/// response rewriting while preserving the [credentials] required by the
96+
/// googleapis request pipeline.
97+
class _SpecialDoubleAuthClient extends _SpecialDoubleClient
98+
implements googleapis_auth.AuthClient {
99+
_SpecialDoubleAuthClient(this._authInner) : super(_authInner);
100+
101+
final googleapis_auth.AuthClient _authInner;
102+
103+
@override
104+
googleapis_auth.AccessCredentials get credentials => _authInner.credentials;
105+
106+
@override
107+
void close() => _authInner.close();
108+
}
109+
28110
/// Internal HTTP request implementation that wraps a stream.
29111
///
30112
/// This is used by [EmulatorClient] to create modified requests with
@@ -114,22 +196,28 @@ class FirestoreHttpClient {
114196
/// Creates the appropriate HTTP client based on emulator configuration.
115197
Future<googleapis_auth.AuthClient> _createClient() async {
116198
if (_isUsingEmulator) {
117-
// Emulator: Create unauthenticated client
118-
return EmulatorClient(Client());
199+
// Emulator: Create unauthenticated client, wrapped to rewrite special
200+
// doubles before EmulatorClient adds auth headers.
201+
return EmulatorClient(_SpecialDoubleClient(Client()));
119202
}
120203

121-
// Production: Create authenticated client
204+
// Production: Create authenticated client, then wrap to rewrite special
205+
// doubles in responses before googleapis parses them.
122206
final serviceAccountCreds = credential.serviceAccountCredentials;
123207
if (serviceAccountCreds != null) {
124-
return googleapis_auth.clientViaServiceAccount(serviceAccountCreds, [
125-
firestore_v1.FirestoreApi.cloudPlatformScope,
126-
]);
208+
final authClient = await googleapis_auth.clientViaServiceAccount(
209+
serviceAccountCreds,
210+
[firestore_v1.FirestoreApi.cloudPlatformScope],
211+
);
212+
return _SpecialDoubleAuthClient(authClient);
127213
}
128214

129215
// Fall back to Application Default Credentials
130-
return googleapis_auth.clientViaApplicationDefaultCredentials(
131-
scopes: [firestore_v1.FirestoreApi.cloudPlatformScope],
132-
);
216+
final authClient = await googleapis_auth
217+
.clientViaApplicationDefaultCredentials(
218+
scopes: [firestore_v1.FirestoreApi.cloudPlatformScope],
219+
);
220+
return _SpecialDoubleAuthClient(authClient);
133221
}
134222

135223
Future<R> _run<R>(

packages/google_cloud_firestore/lib/src/serializer.dart

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,46 @@ part of 'firestore.dart';
1717
@internal
1818
typedef ApiMapValue = Map<String, firestore_v1.Value>;
1919

20+
// Sentinel string values used to round-trip special IEEE 754 doubles through
21+
// the googleapis JSON deserialiser, which cannot handle non-finite doubles.
22+
//
23+
// [_SpecialDoubleClient] in firestore_http_client.dart rewrites incoming
24+
// Firestore REST API responses:
25+
// "doubleValue":"Infinity" → "stringValue":"<kInfinitySentinel>"
26+
// "doubleValue":"-Infinity" → "stringValue":"<kNegInfinitySentinel>"
27+
// "doubleValue":"NaN" → "stringValue":"<kNaNSentinel>"
28+
//
29+
// [Serializer.decodeValue] then maps each sentinel back to the corresponding
30+
// Dart double constant. The names are package-internal (no `_` prefix) so
31+
// that firestore_http_client.dart can reference them via its barrel import.
32+
@internal
33+
const kInfinitySentinel = '__fs_double_infinity__';
34+
@internal
35+
const kNegInfinitySentinel = '__fs_double_neg_infinity__';
36+
@internal
37+
const kNaNSentinel = '__fs_double_nan__';
38+
2039
abstract base class _Serializable {
2140
firestore_v1.Value _toProto();
2241
}
2342

43+
/// A [firestore_v1.Value] subclass that overrides [toJson] to emit the
44+
/// Firestore REST API string representations of special IEEE 754 doubles
45+
/// (`"Infinity"`, `"-Infinity"`, `"NaN"`).
46+
///
47+
/// Dart's [jsonEncode] (and therefore the googleapis serialiser) throws on
48+
/// [double.infinity] and [double.nan] because they are not valid JSON.
49+
/// The Firestore REST API expects them as the strings above, so we bypass
50+
/// standard serialisation by overriding [toJson] here.
51+
class _SpecialDoubleValue extends firestore_v1.Value {
52+
_SpecialDoubleValue._(this._doubleString) : super();
53+
54+
final String _doubleString;
55+
56+
@override
57+
Map<String, dynamic> toJson() => {'doubleValue': _doubleString};
58+
}
59+
2460
class Serializer {
2561
Serializer._(this.firestore);
2662

@@ -83,6 +119,12 @@ class Serializer {
83119
case BigInt():
84120
return firestore_v1.Value(integerValue: value.toString());
85121

122+
case double() when value.isInfinite:
123+
return _SpecialDoubleValue._(value > 0 ? 'Infinity' : '-Infinity');
124+
125+
case double() when value.isNaN:
126+
return _SpecialDoubleValue._('NaN');
127+
86128
case double():
87129
return firestore_v1.Value(doubleValue: value);
88130

@@ -140,7 +182,15 @@ class Serializer {
140182

141183
switch (proto) {
142184
case firestore_v1.Value(:final stringValue?):
143-
return stringValue;
185+
// The HTTP response interceptor rewrites special double strings
186+
// ("Infinity", "-Infinity", "NaN") as sentinel stringValues so that
187+
// googleapis can parse the response without casting a String to num.
188+
return switch (stringValue) {
189+
kInfinitySentinel => double.infinity,
190+
kNegInfinitySentinel => double.negativeInfinity,
191+
kNaNSentinel => double.nan,
192+
_ => stringValue,
193+
};
144194
case firestore_v1.Value(:final booleanValue?):
145195
return booleanValue;
146196
case firestore_v1.Value(:final integerValue?):

packages/google_cloud_firestore/test/integration/firestore_test.dart

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,53 @@
1212
// See the License for the specific language governing permissions and
1313
// limitations under the License.
1414

15+
import 'dart:convert';
16+
import 'dart:io';
17+
1518
import 'package:google_cloud_firestore/google_cloud_firestore.dart';
19+
import 'package:http/http.dart' as http;
1620
import 'package:test/test.dart';
1721

1822
import '../fixtures/helpers.dart';
1923

24+
/// Seeds a Firestore document directly via the emulator REST API, bypassing
25+
/// the Dart SDK's serializer. This lets us create documents with special double
26+
/// values (Infinity, -Infinity, NaN) that the SDK's write path currently can't
27+
/// encode, so we can test the read path independently.
28+
Future<void> _seedDocumentWithSpecialDoubles(
29+
String docPath,
30+
Map<String, Object?> fields,
31+
) async {
32+
final emulatorHost = Platform.environment['FIRESTORE_EMULATOR_HOST']!;
33+
final uri = Uri.http(
34+
emulatorHost,
35+
'/v1/projects/$projectId/databases/(default)/documents/$docPath',
36+
);
37+
38+
final encodedFields = fields.map((key, value) {
39+
final encoded = switch (value) {
40+
double.infinity => {'doubleValue': 'Infinity'},
41+
double.negativeInfinity => {'doubleValue': '-Infinity'},
42+
_ when value is double && value.isNaN => {'doubleValue': 'NaN'},
43+
_ => throw ArgumentError('Unsupported seed value: $value'),
44+
};
45+
return MapEntry(key, encoded);
46+
});
47+
48+
final response = await http.patch(
49+
uri,
50+
headers: {'Content-Type': 'application/json'},
51+
body: jsonEncode({'fields': encodedFields}),
52+
);
53+
54+
if (response.statusCode != 200) {
55+
throw StateError(
56+
'Failed to seed document at $docPath: '
57+
'${response.statusCode} ${response.body}',
58+
);
59+
}
60+
}
61+
2062
void main() {
2163
group('Firestore', () {
2264
late Firestore firestore;
@@ -67,5 +109,83 @@ void main() {
67109
);
68110
});
69111
});
112+
113+
group('special IEEE 754 double values', () {
114+
group('write path', () {
115+
test('set() round-trips double.infinity', () async {
116+
final ref = firestore.collection('special-doubles').doc();
117+
await ref.set({'value': double.infinity});
118+
119+
final data = (await ref.get()).data()!;
120+
expect(data['value'], double.infinity);
121+
});
122+
123+
test('set() round-trips double.negativeInfinity', () async {
124+
final ref = firestore.collection('special-doubles').doc();
125+
await ref.set({'value': double.negativeInfinity});
126+
127+
final data = (await ref.get()).data()!;
128+
expect(data['value'], double.negativeInfinity);
129+
});
130+
131+
test('set() round-trips double.nan', () async {
132+
final ref = firestore.collection('special-doubles').doc();
133+
await ref.set({'value': double.nan});
134+
135+
final data = (await ref.get()).data()!;
136+
expect(data['value'], isNaN);
137+
});
138+
});
139+
140+
group('read path', () {
141+
test('get() decodes Infinity seeded via REST API', () async {
142+
final ref = firestore.collection('special-doubles').doc();
143+
await _seedDocumentWithSpecialDoubles('special-doubles/${ref.id}', {
144+
'value': double.infinity,
145+
});
146+
147+
final data = (await ref.get()).data()!;
148+
expect(data['value'], double.infinity);
149+
});
150+
151+
test('get() decodes -Infinity seeded via REST API', () async {
152+
final ref = firestore.collection('special-doubles').doc();
153+
await _seedDocumentWithSpecialDoubles('special-doubles/${ref.id}', {
154+
'value': double.negativeInfinity,
155+
});
156+
157+
final data = (await ref.get()).data()!;
158+
expect(data['value'], double.negativeInfinity);
159+
});
160+
161+
test('get() decodes NaN seeded via REST API', () async {
162+
final ref = firestore.collection('special-doubles').doc();
163+
await _seedDocumentWithSpecialDoubles('special-doubles/${ref.id}', {
164+
'value': double.nan,
165+
});
166+
167+
final data = (await ref.get()).data()!;
168+
expect(data['value'], isNaN);
169+
});
170+
});
171+
172+
group('query path', () {
173+
test('query results decode documents with Infinity', () async {
174+
final ref = firestore.collection('special-doubles-query').doc();
175+
await _seedDocumentWithSpecialDoubles(
176+
'special-doubles-query/${ref.id}',
177+
{'value': double.infinity},
178+
);
179+
180+
final results = await firestore
181+
.collection('special-doubles-query')
182+
.get();
183+
184+
expect(results.docs, isNotEmpty);
185+
final data = results.docs.first.data();
186+
expect(data['value'], double.infinity);
187+
});
188+
});
189+
});
70190
});
71191
}

0 commit comments

Comments
 (0)