Skip to content

Commit ad115af

Browse files
Adding Timestamp class
1 parent 7bbe970 commit ad115af

16 files changed

+692
-44
lines changed

src/document.js

Lines changed: 29 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,11 @@ const DeleteTransform = fieldValue.DeleteTransform;
5050
*/
5151
const ServerTimestampTransform = fieldValue.ServerTimestampTransform;
5252

53+
/*!
54+
* @see {Timestamp}
55+
*/
56+
const Timestamp = require('./timestamp');
57+
5358
/*!
5459
* Injected.
5560
*
@@ -67,13 +72,6 @@ let validate;
6772
*/
6873
const MAX_DEPTH = 20;
6974

70-
/*!
71-
* Number of nanoseconds in a millisecond.
72-
*
73-
* @type {number}
74-
*/
75-
const MS_TO_NANOS = 1000000;
76-
7775
/**
7876
* An immutable object representing a geographic location in Firestore. The
7977
* location is represented as a latitude/longitude pair.
@@ -98,8 +96,8 @@ class GeoPoint {
9896
* });
9997
*/
10098
constructor(latitude, longitude) {
101-
validate.isNumber('latitude', latitude);
102-
validate.isNumber('longitude', longitude);
99+
validate.isNumber('latitude', latitude, -90, 90);
100+
validate.isNumber('longitude', longitude, -180, 180);
103101

104102
this._latitude = latitude;
105103
this._longitude = longitude;
@@ -532,6 +530,9 @@ class DocumentSnapshot {
532530
* @returns {*} The converted JS type.
533531
*/
534532
_decodeValue(proto) {
533+
const timestampsInSnapshotsEnabled = this._ref.firestore
534+
._timestampsInSnapshotsEnabled;
535+
535536
switch (proto.valueType) {
536537
case 'stringValue': {
537538
return proto.stringValue;
@@ -546,10 +547,11 @@ class DocumentSnapshot {
546547
return parseFloat(proto.doubleValue, 10);
547548
}
548549
case 'timestampValue': {
549-
return new Date(
550-
(proto.timestampValue.seconds || 0) * 1000 +
551-
(proto.timestampValue.nanos || 0) / MS_TO_NANOS
550+
const timestamp = new Timestamp(
551+
Number(proto.timestampValue.seconds || 0),
552+
Number(proto.timestampValue.nanos || 0)
552553
);
554+
return timestampsInSnapshotsEnabled ? timestamp : timestamp.toDate();
553555
}
554556
case 'referenceValue': {
555557
return new DocumentReference(
@@ -733,15 +735,21 @@ class DocumentSnapshot {
733735
};
734736
}
735737

736-
if (is.date(val)) {
737-
let epochSeconds = Math.floor(val.getTime() / 1000);
738-
let timestamp = {
739-
seconds: epochSeconds,
740-
nanos: (val.getTime() - epochSeconds * 1000) * MS_TO_NANOS,
738+
if (is.instance(val, Timestamp)) {
739+
return {
740+
valueType: 'timestampValue',
741+
timestampValue: {seconds: val.seconds, nanos: val.nanoseconds},
741742
};
743+
}
744+
745+
if (is.date(val)) {
746+
let timestamp = Timestamp.fromDate(val);
742747
return {
743748
valueType: 'timestampValue',
744-
timestampValue: timestamp,
749+
timestampValue: {
750+
seconds: timestamp.seconds,
751+
nanos: timestamp.nanoseconds,
752+
},
745753
};
746754
}
747755

@@ -1357,7 +1365,7 @@ class DocumentTransform {
13571365
* Returns the array of fields in this DocumentTransform.
13581366
*
13591367
* @private
1360-
* @type {Array.<FieldPath>} The fields specified in this DocumentTransform.
1368+
* @type {Array.<FieldPath>}
13611369
* @readonly
13621370
*/
13631371
get fields() {
@@ -1550,6 +1558,8 @@ function validateFieldValue(val, options, depth) {
15501558
return true;
15511559
} else if (is.instanceof(val, GeoPoint)) {
15521560
return true;
1561+
} else if (is.instanceof(val, Timestamp)) {
1562+
return true;
15531563
} else if (is.instanceof(val, FieldPath)) {
15541564
throw new Error(
15551565
'Cannot use object of type "FieldPath" as a Firestore value.'

src/index.js

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,25 @@ const GRPC_UNAVAILABLE = 14;
197197
class Firestore {
198198
/**
199199
* @param {Object=} options - [Configuration object](#/docs).
200+
* @param {string=} options.projectId The Firestore Project ID. Can be
201+
* omitted in environments that support `Application Default Credentials`
202+
* {@see https://cloud.google.com/docs/authentication}
203+
* @param {string=} options.keyFilename Local file containing the Service
204+
* Account credentials. Can be omitted in environments that support
205+
* `Application Default Credentials`
206+
* @param {boolean=} options.timestampsInSnapshots Enables the use of
207+
* `Timestamp`s for timestamp fields in `DocumentSnapshots`.<br/>
208+
* Currently, Firestore returns timestamp fields as `Date` but `Date` only
209+
* supports millisecond precision, which leads to truncation and causes
210+
* unexpected behavior when using a timestamp from a snapshot as a part
211+
* of a subsequent query.<br/>
212+
* Setting `timestampsInSnapshots` to true will cause Firestore to return
213+
* `Timestamp` values instead of `Date` avoiding this kind of problem. To
214+
* make this work you must also change any code that uses `Date` to use
215+
* `Timestamp` instead.<br/>
216+
* NOTE: in the future `timestampsInSnapshots: true` will become the
217+
* default and this option will be removed so you should change your code to
218+
* use Timestamp now and opt-in to this new behavior as soon as you can.
200219
*/
201220
constructor(options) {
202221
options = extend({}, options, {
@@ -240,6 +259,34 @@ class Firestore {
240259
Firestore.log('Firestore', 'Detected GCF environment');
241260
}
242261

262+
this._timestampsInSnapshotsEnabled = !!options.timestampsInSnapshots;
263+
264+
if (!this._timestampsInSnapshotsEnabled) {
265+
// eslint-disable-next-line no-console
266+
console.error(`
267+
The behavior for Date objects stored in Firestore is going to change
268+
AND YOUR APP MAY BREAK.
269+
To hide this warning and ensure your app does not break, you need to add the
270+
following code to your app before calling any other Cloud Firestore methods:
271+
272+
const settings = {/* your settings... */ timestampsInSnapshots: true};
273+
const firestore = new Firestore(settings);
274+
275+
With this change, timestamps stored in Cloud Firestore will be read back as
276+
Firebase Timestamp objects instead of as system Date objects. So you will also
277+
need to update code expecting a Date to instead expect a Timestamp. For example:
278+
279+
// Old:
280+
const date = snapshot.get('created_at');
281+
// New:
282+
const timestamp = snapshot.get('created_at');
283+
const date = timestamp.toDate();
284+
285+
Please audit all existing usages of Date when you enable the new behavior. In a
286+
future release, the behavior will change to the new behavior, so if you do not
287+
follow these steps, YOUR APP MAY BREAK.`);
288+
}
289+
243290
if (options && options.projectId) {
244291
validate.isString('options.projectId', options.projectId);
245292
this._referencePath = new ResourcePath(options.projectId, '(default)');
@@ -1317,3 +1364,12 @@ module.exports.FieldValue = FieldValue;
13171364
* @type {Constructor}
13181365
*/
13191366
module.exports.FieldPath = FieldPath;
1367+
1368+
/**
1369+
* {@link Timestamp} class.
1370+
*
1371+
* @name Firestore.Timestamp
1372+
* @see Timestamp
1373+
* @type Timestamp
1374+
*/
1375+
module.exports.Timestamp = require('./timestamp');

src/timestamp.js

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
/*!
2+
* Copyright 2018 Google Inc. All Rights Reserved.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
'use strict';
18+
19+
const is = require('is');
20+
const validate = require('./validate')();
21+
22+
/*!
23+
* Number of nanoseconds in a millisecond.
24+
*
25+
* @type {number}
26+
*/
27+
const MS_TO_NANOS = 1000000;
28+
29+
/**
30+
* A Timestamp represents a point in time independent of any time zone or
31+
* calendar, represented as seconds and fractions of seconds at nanosecond
32+
* resolution in UTC Epoch time. It is encoded using the Proleptic Gregorian
33+
* Calendar which extends the Gregorian calendar backwards to year one. It is
34+
* encoded assuming all minutes are 60 seconds long, i.e. leap seconds are
35+
* "smeared" so that no leap second table is needed for interpretation. Range is
36+
* from 0001-01-01T00:00:00Z to 9999-12-31T23:59:59.999999999Z.
37+
*
38+
* @see https://github.com/google/protobuf/blob/master/src/google/protobuf/timestamp.proto
39+
*/
40+
class Timestamp {
41+
/**
42+
* Creates a new timestamp with the current date, with millisecond precision.
43+
*
44+
* @return {Timestamp} A new `Timestamp` representing the current date.
45+
*/
46+
static now() {
47+
return Timestamp.fromMillis(Date.now());
48+
}
49+
50+
/**
51+
* Creates a new timestamp from the given date.
52+
*
53+
* @param {Date} date The date to initialize the `Timestamp` from.
54+
* @return {Timestamp} A new `Timestamp` representing the same point in time
55+
* as the given date.
56+
*/
57+
static fromDate(date) {
58+
return Timestamp.fromMillis(date.getTime());
59+
}
60+
61+
/**
62+
* Creates a new timestamp from the given number of milliseconds.
63+
*
64+
* @param {number} milliseconds Number of milliseconds since Unix epoch
65+
* 1970-01-01T00:00:00Z.
66+
* @return {Timestamp} A new `Timestamp` representing the same point in time
67+
* as the given number of milliseconds.
68+
*/
69+
static fromMillis(milliseconds) {
70+
const seconds = Math.floor(milliseconds / 1000);
71+
const nanos = (milliseconds - seconds * 1000) * MS_TO_NANOS;
72+
return new Timestamp(seconds, nanos);
73+
}
74+
75+
/**
76+
* Creates a new timestamp.
77+
*
78+
* @param {number} seconds The number of seconds of UTC time since Unix epoch
79+
* 1970-01-01T00:00:00Z. Must be from 0001-01-01T00:00:00Z to
80+
* 9999-12-31T23:59:59Z inclusive.
81+
* @param {number} nanoseconds The non-negative fractions of a second at
82+
* nanosecond resolution. Negative second values with fractions must still
83+
* have non-negative nanoseconds values that count forward in time. Must
84+
* be from 0 to 999,999,999 inclusive.
85+
*/
86+
constructor(seconds, nanoseconds) {
87+
validate.isInteger('seconds', seconds);
88+
validate.isInteger('nanoseconds', nanoseconds, 0, 999999999);
89+
90+
this._seconds = seconds;
91+
this._nanoseconds = nanoseconds;
92+
}
93+
94+
/**
95+
* The number of seconds of UTC time since Unix epoch 1970-01-01T00:00:00Z.
96+
*
97+
* @type {number}
98+
*/
99+
get seconds() {
100+
return this._seconds;
101+
}
102+
103+
/**
104+
* The non-negative fractions of a second at nanosecond resolution.
105+
*
106+
* @type {number}
107+
*/
108+
get nanoseconds() {
109+
return this._nanoseconds;
110+
}
111+
112+
/**
113+
* Returns a new `Date` corresponding to this timestamp. This may lose
114+
* precision.
115+
*
116+
* @return {Date} JavaScript `Date` object representing the same point in time
117+
* as this `Timestamp`, with millisecond precision.
118+
*/
119+
toDate() {
120+
return new Date(
121+
this._seconds * 1000 + Math.round(this._nanoseconds / MS_TO_NANOS)
122+
);
123+
}
124+
125+
/**
126+
* Returns the number of milliseconds since Unix epoch 1970-01-01T00:00:00Z.
127+
*
128+
* @return {number} The point in time corresponding to this timestamp,
129+
* represented as the number of milliseconds since Unix epoch
130+
* 1970-01-01T00:00:00Z.
131+
*/
132+
toMillis() {
133+
return this._seconds * 1000 + Math.floor(this._nanoseconds / MS_TO_NANOS);
134+
}
135+
136+
/**
137+
* Returns true if this `Timestamp` is equal to the provided one.
138+
*
139+
* @param {any} other The `Timestamp` to compare against.
140+
* @return {boolean} true if this `Timestamp` is equal to the provided one.
141+
*/
142+
isEqual(other) {
143+
return (
144+
this === other ||
145+
(is.instanceof(other, Timestamp) &&
146+
this._seconds === other.seconds &&
147+
this._nanoseconds === other.nanoseconds)
148+
);
149+
}
150+
}
151+
152+
module.exports = Timestamp;

src/validate.js

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,12 +52,24 @@ module.exports = validators => {
5252
integer: (value, min, max) => {
5353
min = is.defined(min) ? min : -Infinity;
5454
max = is.defined(max) ? max : Infinity;
55-
return is.integer(value) && value >= min && value <= max;
55+
if (!is.integer(value)) {
56+
return false;
57+
}
58+
if (value < min || value > max) {
59+
throw new Error(`Value must be within [${min}, ${max}] inclusive.`);
60+
}
61+
return true;
5662
},
5763
number: (value, min, max) => {
5864
min = is.defined(min) ? min : -Infinity;
5965
max = is.defined(max) ? max : Infinity;
60-
return is.number(value) && value >= min && value <= max;
66+
if (!is.number(value) || is.nan(value)) {
67+
return false;
68+
}
69+
if (value < min || value > max) {
70+
throw new Error(`Value must be within [${min}, ${max}] inclusive.`);
71+
}
72+
return true;
6173
},
6274
object: is.object,
6375
string: is.string,
@@ -148,6 +160,7 @@ module.exports = validators => {
148160
case 'FieldPath':
149161
case 'FieldValue':
150162
case 'GeoPoint':
163+
case 'Timestamp':
151164
return new Error(
152165
`Detected an object of type "${typeName}" that doesn't match the ` +
153166
'expected instance. Please ensure that the Firestore types you ' +

0 commit comments

Comments
 (0)