From f9a03ef10d9de4349a0bd5f5184e1a438e2cf061 Mon Sep 17 00:00:00 2001 From: Sebastian Schmidt Date: Mon, 25 Jun 2018 13:02:08 -0700 Subject: [PATCH 1/5] Adding Timestamp class --- src/document.js | 48 +++++---- src/index.js | 57 ++++++++++ src/timestamp.js | 152 +++++++++++++++++++++++++++ src/validate.js | 17 ++- system-test/firestore.js | 14 +-- test/collection.js | 1 + test/document.js | 31 ++++-- test/index.js | 55 +++++++++- test/order.js | 1 + test/query.js | 1 + test/timestamp.js | 218 +++++++++++++++++++++++++++++++++++++++ test/transaction.js | 1 + test/typescript.ts | 21 +++- test/watch.js | 1 + test/write-batch.js | 1 + types/firestore.d.ts | 127 ++++++++++++++++++++++- 16 files changed, 702 insertions(+), 44 deletions(-) create mode 100644 src/timestamp.js create mode 100644 test/timestamp.js diff --git a/src/document.js b/src/document.js index 06721de45..32eaee3d6 100644 --- a/src/document.js +++ b/src/document.js @@ -50,6 +50,11 @@ const DeleteTransform = fieldValue.DeleteTransform; */ const ServerTimestampTransform = fieldValue.ServerTimestampTransform; +/*! + * @see {Timestamp} + */ +const Timestamp = require('./timestamp'); + /*! * Injected. * @@ -67,13 +72,6 @@ let validate; */ const MAX_DEPTH = 20; -/*! - * Number of nanoseconds in a millisecond. - * - * @type {number} - */ -const MS_TO_NANOS = 1000000; - /** * An immutable object representing a geographic location in Firestore. The * location is represented as a latitude/longitude pair. @@ -98,8 +96,8 @@ class GeoPoint { * }); */ constructor(latitude, longitude) { - validate.isNumber('latitude', latitude); - validate.isNumber('longitude', longitude); + validate.isNumber('latitude', latitude, -90, 90); + validate.isNumber('longitude', longitude, -180, 180); this._latitude = latitude; this._longitude = longitude; @@ -532,6 +530,9 @@ class DocumentSnapshot { * @returns {*} The converted JS type. */ _decodeValue(proto) { + const timestampsInSnapshotsEnabled = this._ref.firestore + ._timestampsInSnapshotsEnabled; + switch (proto.valueType) { case 'stringValue': { return proto.stringValue; @@ -546,10 +547,11 @@ class DocumentSnapshot { return parseFloat(proto.doubleValue, 10); } case 'timestampValue': { - return new Date( - (proto.timestampValue.seconds || 0) * 1000 + - (proto.timestampValue.nanos || 0) / MS_TO_NANOS + const timestamp = new Timestamp( + Number(proto.timestampValue.seconds || 0), + Number(proto.timestampValue.nanos || 0) ); + return timestampsInSnapshotsEnabled ? timestamp : timestamp.toDate(); } case 'referenceValue': { return new DocumentReference( @@ -733,15 +735,21 @@ class DocumentSnapshot { }; } - if (is.date(val)) { - let epochSeconds = Math.floor(val.getTime() / 1000); - let timestamp = { - seconds: epochSeconds, - nanos: (val.getTime() - epochSeconds * 1000) * MS_TO_NANOS, + if (is.instance(val, Timestamp)) { + return { + valueType: 'timestampValue', + timestampValue: {seconds: val.seconds, nanos: val.nanoseconds}, }; + } + + if (is.date(val)) { + let timestamp = Timestamp.fromDate(val); return { valueType: 'timestampValue', - timestampValue: timestamp, + timestampValue: { + seconds: timestamp.seconds, + nanos: timestamp.nanoseconds, + }, }; } @@ -1357,7 +1365,7 @@ class DocumentTransform { * Returns the array of fields in this DocumentTransform. * * @private - * @type {Array.} The fields specified in this DocumentTransform. + * @type {Array.} * @readonly */ get fields() { @@ -1550,6 +1558,8 @@ function validateFieldValue(val, options, depth) { return true; } else if (is.instanceof(val, GeoPoint)) { return true; + } else if (is.instanceof(val, Timestamp)) { + return true; } else if (is.instanceof(val, FieldPath)) { throw new Error( 'Cannot use object of type "FieldPath" as a Firestore value.' diff --git a/src/index.js b/src/index.js index 26eba7c3f..8c00f3bcf 100644 --- a/src/index.js +++ b/src/index.js @@ -197,6 +197,26 @@ const GRPC_UNAVAILABLE = 14; class Firestore { /** * @param {Object=} options - [Configuration object](#/docs). + * @param {string=} options.projectId The Firestore Project ID. Can be + * omitted in environments that support `Application Default Credentials` + * {@see https://cloud.google.com/docs/authentication} + * @param {string=} options.keyFilename Local file containing the Service + * Account credentials. Can be omitted in environments that support + * `Application Default Credentials` + * {@see https://cloud.google.com/docs/authentication} + * @param {boolean=} options.timestampsInSnapshots Enables the use of + * `Timestamp`s for timestamp fields in `DocumentSnapshots`.
+ * Currently, Firestore returns timestamp fields as `Date` but `Date` only + * supports millisecond precision, which leads to truncation and causes + * unexpected behavior when using a timestamp from a snapshot as a part + * of a subsequent query. + *
Setting `timestampsInSnapshots` to true will cause Firestore to return + * `Timestamp` values instead of `Date` avoiding this kind of problem. To + * make this work you must also change any code that uses `Date` to use + * `Timestamp` instead. + *
NOTE: in the future `timestampsInSnapshots: true` will become the + * default and this option will be removed so you should change your code to + * use Timestamp now and opt-in to this new behavior as soon as you can. */ constructor(options) { options = extend({}, options, { @@ -240,6 +260,34 @@ class Firestore { Firestore.log('Firestore', 'Detected GCF environment'); } + this._timestampsInSnapshotsEnabled = !!options.timestampsInSnapshots; + + if (!this._timestampsInSnapshotsEnabled) { + // eslint-disable-next-line no-console + console.error(` +The behavior for Date objects stored in Firestore is going to change +AND YOUR APP MAY BREAK. +To hide this warning and ensure your app does not break, you need to add the +following code to your app before calling any other Cloud Firestore methods: + + const settings = {/* your settings... */ timestampsInSnapshots: true}; + const firestore = new Firestore(settings); + +With this change, timestamps stored in Cloud Firestore will be read back as +Firebase Timestamp objects instead of as system Date objects. So you will also +need to update code expecting a Date to instead expect a Timestamp. For example: + + // Old: + const date = snapshot.get('created_at'); + // New: + const timestamp = snapshot.get('created_at'); + const date = timestamp.toDate(); + +Please audit all existing usages of Date when you enable the new behavior. In a +future release, the behavior will change to the new behavior, so if you do not +follow these steps, YOUR APP MAY BREAK.`); + } + if (options && options.projectId) { validate.isString('options.projectId', options.projectId); this._referencePath = new ResourcePath(options.projectId, '(default)'); @@ -1317,3 +1365,12 @@ module.exports.FieldValue = FieldValue; * @type {Constructor} */ module.exports.FieldPath = FieldPath; + +/** + * {@link Timestamp} class. + * + * @name Firestore.Timestamp + * @see Timestamp + * @type Timestamp + */ +module.exports.Timestamp = require('./timestamp'); diff --git a/src/timestamp.js b/src/timestamp.js new file mode 100644 index 000000000..0b51a569c --- /dev/null +++ b/src/timestamp.js @@ -0,0 +1,152 @@ +/*! + * Copyright 2018 Google Inc. All Rights Reserved. + * + * 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 + * + * http://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. + */ + +'use strict'; + +const is = require('is'); +const validate = require('./validate')(); + +/*! + * Number of nanoseconds in a millisecond. + * + * @type {number} + */ +const MS_TO_NANOS = 1000000; + +/** + * A Timestamp represents a point in time independent of any time zone or + * calendar, represented as seconds and fractions of seconds at nanosecond + * resolution in UTC Epoch time. It is encoded using the Proleptic Gregorian + * Calendar which extends the Gregorian calendar backwards to year one. It is + * encoded assuming all minutes are 60 seconds long, i.e. leap seconds are + * "smeared" so that no leap second table is needed for interpretation. Range is + * from 0001-01-01T00:00:00Z to 9999-12-31T23:59:59.999999999Z. + * + * @see https://github.com/google/protobuf/blob/master/src/google/protobuf/timestamp.proto + */ +class Timestamp { + /** + * Creates a new timestamp with the current date, with millisecond precision. + * + * @return {Timestamp} A new `Timestamp` representing the current date. + */ + static now() { + return Timestamp.fromMillis(Date.now()); + } + + /** + * Creates a new timestamp from the given date. + * + * @param {Date} date The date to initialize the `Timestamp` from. + * @return {Timestamp} A new `Timestamp` representing the same point in time + * as the given date. + */ + static fromDate(date) { + return Timestamp.fromMillis(date.getTime()); + } + + /** + * Creates a new timestamp from the given number of milliseconds. + * + * @param {number} milliseconds Number of milliseconds since Unix epoch + * 1970-01-01T00:00:00Z. + * @return {Timestamp} A new `Timestamp` representing the same point in time + * as the given number of milliseconds. + */ + static fromMillis(milliseconds) { + const seconds = Math.floor(milliseconds / 1000); + const nanos = (milliseconds - seconds * 1000) * MS_TO_NANOS; + return new Timestamp(seconds, nanos); + } + + /** + * Creates a new timestamp. + * + * @param {number} seconds The number of seconds of UTC time since Unix epoch + * 1970-01-01T00:00:00Z. Must be from 0001-01-01T00:00:00Z to + * 9999-12-31T23:59:59Z inclusive. + * @param {number} nanoseconds The non-negative fractions of a second at + * nanosecond resolution. Negative second values with fractions must still + * have non-negative nanoseconds values that count forward in time. Must be + * from 0 to 999,999,999 inclusive. + */ + constructor(seconds, nanoseconds) { + validate.isInteger('seconds', seconds); + validate.isInteger('nanoseconds', nanoseconds, 0, 999999999); + + this._seconds = seconds; + this._nanoseconds = nanoseconds; + } + + /** + * The number of seconds of UTC time since Unix epoch 1970-01-01T00:00:00Z. + * + * @type {number} + */ + get seconds() { + return this._seconds; + } + + /** + * The non-negative fractions of a second at nanosecond resolution. + * + * @type {number} + */ + get nanoseconds() { + return this._nanoseconds; + } + + /** + * Returns a new `Date` corresponding to this timestamp. This may lose + * precision. + * + * @return {Date} JavaScript `Date` object representing the same point in time + * as this `Timestamp`, with millisecond precision. + */ + toDate() { + return new Date( + this._seconds * 1000 + Math.round(this._nanoseconds / MS_TO_NANOS) + ); + } + + /** + * Returns the number of milliseconds since Unix epoch 1970-01-01T00:00:00Z. + * + * @return {number} The point in time corresponding to this timestamp, + * represented as the number of milliseconds since Unix epoch + * 1970-01-01T00:00:00Z. + */ + toMillis() { + return this._seconds * 1000 + Math.floor(this._nanoseconds / MS_TO_NANOS); + } + + /** + * Returns 'true' if this `Timestamp` is equal to the provided one. + * + * @param {any} other The `Timestamp` to compare against. + * @return {boolean} 'true' if this `Timestamp` is equal to the provided one. + */ + isEqual(other) { + return ( + this === other || + (is.instanceof(other, Timestamp) && + this._seconds === other.seconds && + this._nanoseconds === other.nanoseconds) + ); + } +} + +module.exports = Timestamp; diff --git a/src/validate.js b/src/validate.js index db0bab8e9..b1f78ebdb 100644 --- a/src/validate.js +++ b/src/validate.js @@ -52,12 +52,24 @@ module.exports = validators => { integer: (value, min, max) => { min = is.defined(min) ? min : -Infinity; max = is.defined(max) ? max : Infinity; - return is.integer(value) && value >= min && value <= max; + if (!is.integer(value)) { + return false; + } + if (value < min || value > max) { + throw new Error(`Value must be within [${min}, ${max}] inclusive.`); + } + return true; }, number: (value, min, max) => { min = is.defined(min) ? min : -Infinity; max = is.defined(max) ? max : Infinity; - return is.number(value) && value >= min && value <= max; + if (!is.number(value) || is.nan(value)) { + return false; + } + if (value < min || value > max) { + throw new Error(`Value must be within [${min}, ${max}] inclusive.`); + } + return true; }, object: is.object, string: is.string, @@ -148,6 +160,7 @@ module.exports = validators => { case 'FieldPath': case 'FieldValue': case 'GeoPoint': + case 'Timestamp': return new Error( `Detected an object of type "${typeName}" that doesn't match the ` + 'expected instance. Please ensure that the Firestore types you ' + diff --git a/system-test/firestore.js b/system-test/firestore.js index 4f519f576..c4d9ec875 100644 --- a/system-test/firestore.js +++ b/system-test/firestore.js @@ -39,7 +39,7 @@ describe('Firestore class', function() { let randomCol; beforeEach(function() { - firestore = new Firestore(); + firestore = new Firestore({timestampsInSnapshots: true}); randomCol = getTestRoot(firestore); }); @@ -71,7 +71,7 @@ describe('CollectionReference class', function() { let randomCol; beforeEach(function() { - firestore = new Firestore(); + firestore = new Firestore({timestampsInSnapshots: true}); randomCol = getTestRoot(firestore); }); @@ -119,7 +119,7 @@ describe('DocumentReference class', function() { let randomCol; beforeEach(function() { - firestore = new Firestore(); + firestore = new Firestore({timestampsInSnapshots: true}); randomCol = getTestRoot(firestore); }); @@ -790,7 +790,7 @@ describe('Query class', function() { }; beforeEach(function() { - firestore = new Firestore(); + firestore = new Firestore({timestampsInSnapshots: true}); randomCol = getTestRoot(firestore); }); @@ -1298,7 +1298,7 @@ describe('Transaction class', function() { let randomCol; beforeEach(function() { - firestore = new Firestore(); + firestore = new Firestore({timestampsInSnapshots: true}); randomCol = getTestRoot(firestore); }); @@ -1427,7 +1427,7 @@ describe('WriteBatch class', function() { let randomCol; beforeEach(function() { - firestore = new Firestore(); + firestore = new Firestore({timestampsInSnapshots: true}); randomCol = getTestRoot(firestore); }); @@ -1522,7 +1522,7 @@ describe('QuerySnapshot class', function() { let querySnapshot; beforeEach(function() { - firestore = new Firestore(); + firestore = new Firestore({timestampsInSnapshots: true}); let randomCol = getTestRoot(firestore); let ref1 = randomCol.doc('doc1'); diff --git a/test/collection.js b/test/collection.js index 23a4648bc..7fb4481e0 100644 --- a/test/collection.js +++ b/test/collection.js @@ -31,6 +31,7 @@ function createInstance() { let firestore = new Firestore({ projectId: 'test-project', sslCreds: grpc.credentials.createInsecure(), + timestampsInSnapshots: true, }); return firestore._ensureClient().then(() => firestore); diff --git a/test/document.js b/test/document.js index 6f26709d6..e09f619d9 100644 --- a/test/document.js +++ b/test/document.js @@ -39,6 +39,7 @@ function createInstance() { let firestore = new Firestore({ projectId: 'test-project', sslCreds: grpc.credentials.createInsecure(), + timestampsInSnapshots: true, }); return firestore._ensureClient().then(() => firestore); @@ -447,19 +448,33 @@ describe('serialize document', function() { }); it('with invalid geopoint', function() { - const expectedErr = /Argument ".*" is not a valid number\./; - assert.throws(() => { - new Firestore.GeoPoint('57.2999988', 'INVALID'); - }, expectedErr); + new Firestore.GeoPoint(57.2999988, 'INVALID'); + }, /Argument "longitude" is not a valid number/); assert.throws(() => { - new Firestore.GeoPoint('INVALID', '-4.4499982'); - }, expectedErr); + new Firestore.GeoPoint('INVALID', -4.4499982); + }, /Argument "latitude" is not a valid number/); assert.throws(() => { new Firestore.GeoPoint(); - }, expectedErr); + }, /Argument "latitude" is not a valid number/); + + assert.throws(() => { + new Firestore.GeoPoint(NaN); + }, /Argument "latitude" is not a valid number/); + + assert.throws(() => { + new Firestore.GeoPoint(Infinity); + }, /Argument "latitude" is not a valid number/); + + assert.throws(() => { + new Firestore.GeoPoint(91, 0); + }, /Argument "latitude" is not a valid number. Value must be within \[-90, 90] inclusive/); + + assert.throws(() => { + new Firestore.GeoPoint(90, 181); + }, /Argument "longitude" is not a valid number. Value must be within \[-180, 180] inclusive/); }); it('resolves infinite nesting', function() { @@ -567,7 +582,7 @@ describe('deserialize document', function() { .get() .then(res => { assert.equal( - res.get('moonLanding').getTime(), + res.get('moonLanding').toMillis(), new Date('Jul 20 1969 20:18:00.123 UTC').getTime() ); }); diff --git a/test/index.js b/test/index.js index 901238bfb..677ad28ef 100644 --- a/test/index.js +++ b/test/index.js @@ -66,6 +66,13 @@ const allSupportedTypesProtobufJs = document('documentId', { seconds: 479978400, }, }, + timestampValue: { + valueType: 'timestampValue', + timestampValue: { + nanos: 123000000, + seconds: 479978400, + }, + }, doubleValue: { valueType: 'doubleValue', doubleValue: 0.1, @@ -152,6 +159,9 @@ const allSupportedTypesJson = document('documentId', { dateValue: { timestampValue: '1985-03-18T07:20:00.123000000Z', }, + timestampValue: { + timestampValue: '1985-03-18T07:20:00.123000000Z', + }, doubleValue: { doubleValue: 0.1, }, @@ -202,7 +212,7 @@ const allSupportedTypesJson = document('documentId', { }, }); -const allSupportedTypesObject = { +const allSupportedTypesInput = { stringValue: 'a', trueValue: true, falseValue: false, @@ -213,6 +223,36 @@ const allSupportedTypesObject = { objectValue: {foo: 'bar'}, emptyObject: {}, dateValue: new Date('Mar 18, 1985 08:20:00.123 GMT+0100 (CET)'), + timestampValue: Firestore.Timestamp.fromDate( + new Date('Mar 18, 1985 08:20:00.123 GMT+0100 (CET)') + ), + pathValue: new DocumentReference( + {formattedName: DATABASE_ROOT}, + new ResourcePath('test-project', '(default)', 'collection', 'document') + ), + arrayValue: ['foo', 42, 'bar'], + emptyArray: [], + nilValue: null, + geoPointValue: new Firestore.GeoPoint(50.1430847, -122.947778), + bytesValue: Buffer.from([0x1, 0x2]), +}; + +const allSupportedTypesOutput = { + stringValue: 'a', + trueValue: true, + falseValue: false, + integerValue: 0, + doubleValue: 0.1, + infinityValue: Infinity, + negativeInfinityValue: -Infinity, + objectValue: {foo: 'bar'}, + emptyObject: {}, + dateValue: Firestore.Timestamp.fromDate( + new Date('Mar 18, 1985 08:20:00.123 GMT+0100 (CET)') + ), + timestampValue: Firestore.Timestamp.fromDate( + new Date('Mar 18, 1985 08:20:00.123 GMT+0100 (CET)') + ), pathValue: new DocumentReference( {formattedName: DATABASE_ROOT}, new ResourcePath('test-project', '(default)', 'collection', 'document') @@ -228,6 +268,7 @@ function createInstance() { let firestore = new Firestore({ projectId: 'test-project', sslCreds: grpc.credentials.createInsecure(), + timestampsInSnapshots: true, }); return firestore._ensureClient().then(() => firestore); @@ -279,6 +320,7 @@ describe('instantiation', function() { let firestore = new Firestore({ projectId: 'test-project', sslCreds: grpc.credentials.createInsecure(), + timestampsInSnapshots: true, }); assert(firestore instanceof Firestore); }); @@ -286,6 +328,7 @@ describe('instantiation', function() { it('detects project id', function() { let firestore = new Firestore({ sslCreds: grpc.credentials.createInsecure(), + timestampsInSnapshots: true, }); assert.equal( @@ -320,6 +363,7 @@ describe('instantiation', function() { it('handles error from project ID detection', function() { let firestore = new Firestore({ sslCreds: grpc.credentials.createInsecure(), + timestampsInSnapshots: true, }); let initialized = firestore._ensureClient(); @@ -340,6 +384,8 @@ describe('instantiation', function() { // Ordering as per firestore.d.ts assert.ok(is.defined(Firestore.Firestore)); assert.equal(Firestore.Firestore.name, 'Firestore'); + assert.ok(is.defined(Firestore.Timestamp)); + assert.equal(Firestore.Timestamp.name, 'Timestamp'); assert.ok(is.defined(Firestore.GeoPoint)); assert.equal(Firestore.GeoPoint.name, 'GeoPoint'); assert.ok(is.defined(Firestore.Transaction)); @@ -397,7 +443,7 @@ describe('serializer', function() { }); }; - return firestore.collection('coll').add(allSupportedTypesObject); + return firestore.collection('coll').add(allSupportedTypesInput); }); }); @@ -405,7 +451,7 @@ describe('snapshot_() method', function() { let firestore; function verifyAllSupportedTypes(actualObject) { - let expected = extend(true, {}, allSupportedTypesObject); + let expected = extend(true, {}, allSupportedTypesOutput); // Deep Equal doesn't support matching instances of DocumentRefs, so we // compare them manually and remove them from the resulting object. assert.equal( @@ -438,6 +484,7 @@ describe('snapshot_() method', function() { firestore = new Firestore({ projectId: 'test-project', sslCreds: grpc.credentials.createInsecure(), + timestampsInSnapshots: true, }); }); @@ -480,7 +527,7 @@ describe('snapshot_() method', function() { assert.equal(true, doc.exists); assert.deepEqual(doc.data(), { a: bytesData, - b: new Date('1985-03-18T07:20:00.000Z'), + b: Firestore.Timestamp.fromDate(new Date('1985-03-18T07:20:00.000Z')), c: bytesData, }); assert.equal('1970-01-01T00:00:01.002000000Z', doc.createTime); diff --git a/test/order.js b/test/order.js index 9bdd83536..c0b69a1da 100644 --- a/test/order.js +++ b/test/order.js @@ -35,6 +35,7 @@ function createInstance() { let firestore = new Firestore({ projectId: 'test-project', sslCreds: grpc.credentials.createInsecure(), + timestampsInSnapshots: true, }); return firestore._ensureClient().then(() => firestore); diff --git a/test/query.js b/test/query.js index 50844e30f..03384f91e 100644 --- a/test/query.js +++ b/test/query.js @@ -38,6 +38,7 @@ function createInstance() { let firestore = new Firestore({ projectId: 'test-project', sslCreds: grpc.credentials.createInsecure(), + timestampsInSnapshots: true, }); return firestore._ensureClient().then(() => firestore); diff --git a/test/timestamp.js b/test/timestamp.js new file mode 100644 index 000000000..7eaef52ed --- /dev/null +++ b/test/timestamp.js @@ -0,0 +1,218 @@ +/** + * Copyright 2018 Google Inc. All Rights Reserved. + * + * 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 + * + * http://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. + */ + +'use strict'; + +const grpc = require('google-gax').grpc().grpc; +const Firestore = require('../'); +const is = require('is'); +const through = require('through2'); +const assert = require('assert'); + +function createInstance(opts, document) { + let firestore = new Firestore( + Object.assign({}, opts, { + projectId: 'test-project', + sslCreds: grpc.credentials.createInsecure(), + }) + ); + + return firestore._ensureClient().then(() => { + firestore._firestoreClient._batchGetDocuments = function() { + const stream = through.obj(); + setImmediate(function() { + stream.push({found: document, readTime: {seconds: 5, nanos: 6}}); + stream.push(null); + }); + + return stream; + }; + + return firestore; + }); +} + +function document(field, value) { + let document = { + name: `projects/test-project/databases/(default)/documents/coll/doc`, + fields: {}, + createTime: {}, + updateTime: {}, + }; + + for (let i = 0; i < arguments.length; i += 2) { + field = arguments[i]; + value = arguments[i + 1]; + document.fields[field] = value; + } + + return document; +} + +const DOCUMENT_WITH_TIMESTAMP = document('moonLanding', { + valueType: 'timestampValue', + timestampValue: { + nanos: 123000123, + seconds: -14182920, + }, +}); + +const DOCUMENT_WITH_EMPTY_TIMESTAMP = document('moonLanding', { + valueType: 'timestampValue', + timestampValue: {}, +}); + +describe('timestamps', function() { + it('returned when enabled', function() { + return createInstance( + {timestampsInSnapshots: true}, + DOCUMENT_WITH_TIMESTAMP + ).then(firestore => { + const expected = new Firestore.Timestamp(-14182920, 123000123); + return firestore + .doc('coll/doc') + .get() + .then(res => { + assert.ok(res.data()['moonLanding'].isEqual(expected)); + assert.ok(res.get('moonLanding').isEqual(expected)); + }); + }); + }); + + it('converted to dates when disabled', function() { + const oldErrorLog = console.error; + let errorMsg = null; + // Prevent error message that prompts to enable `timestampsInSnapshots` + // behavior. + console.error = msg => { + errorMsg = msg; + }; + + return createInstance( + {timestampsInSnapshots: false}, + DOCUMENT_WITH_TIMESTAMP + ).then(firestore => { + return firestore + .doc('coll/doc') + .get() + .then(res => { + assert.ok(is.date(res.data()['moonLanding'])); + assert.ok(is.date(res.get('moonLanding'))); + assert.ok(errorMsg.indexOf('timestampsInSnapshots') !== -1); + console.error = oldErrorLog; + }); + }); + }); + + it('retain seconds and nanoseconds', function() { + return createInstance( + {timestampsInSnapshots: true}, + DOCUMENT_WITH_TIMESTAMP + ).then(firestore => { + return firestore + .doc('coll/doc') + .get() + .then(res => { + const timestamp = res.get('moonLanding'); + assert.equal(timestamp.seconds, -14182920); + assert.equal(timestamp.nanoseconds, 123000123); + }); + }); + }); + + it('convert to date', function() { + return createInstance( + {timestampsInSnapshots: true}, + DOCUMENT_WITH_TIMESTAMP + ).then(firestore => { + return firestore + .doc('coll/doc') + .get() + .then(res => { + const timestamp = res.get('moonLanding'); + assert.equal( + new Date(-14182920 * 1000 + 123).getTime(), + timestamp.toDate().getTime() + ); + }); + }); + }); + + it('convert to millis', function() { + return createInstance( + {timestampsInSnapshots: true}, + DOCUMENT_WITH_TIMESTAMP + ).then(firestore => { + return firestore + .doc('coll/doc') + .get() + .then(res => { + const timestamp = res.get('moonLanding'); + assert.equal(-14182920 * 1000 + 123, timestamp.toMillis()); + }); + }); + }); + + it('support missing values', function() { + return createInstance( + {timestampsInSnapshots: true}, + DOCUMENT_WITH_EMPTY_TIMESTAMP + ).then(firestore => { + const expected = new Firestore.Timestamp(0, 0); + + return firestore + .doc('coll/doc') + .get() + .then(res => { + assert.ok(res.get('moonLanding').isEqual(expected)); + }); + }); + }); + + it('constructed using helper', function() { + assert.ok(is.instance(Firestore.Timestamp.now(), Firestore.Timestamp)); + + let actual = Firestore.Timestamp.fromDate(new Date(123123)); + let expected = new Firestore.Timestamp(123, 123000000); + assert.ok(actual.isEqual(expected)); + + actual = Firestore.Timestamp.fromMillis(123123); + expected = new Firestore.Timestamp(123, 123000000); + assert.ok(actual.isEqual(expected)); + }); + + it('validates nanoseconds', function() { + assert.throws( + () => new Firestore.Timestamp(0.1, 0), + /Argument "seconds" is not a valid integer./ + ); + + assert.throws( + () => new Firestore.Timestamp(0, 0.1), + /Argument "nanoseconds" is not a valid integer./ + ); + + assert.throws( + () => new Firestore.Timestamp(0, -1), + /Argument "nanoseconds" is not a valid integer. Value must be within \[0, 999999999] inclusive/ + ); + + assert.throws( + () => new Firestore.Timestamp(0, 1000000000), + /Argument "nanoseconds" is not a valid integer. Value must be within \[0, 999999999] inclusive/ + ); + }); +}); diff --git a/test/transaction.js b/test/transaction.js index a7a71eeff..14da9000e 100644 --- a/test/transaction.js +++ b/test/transaction.js @@ -34,6 +34,7 @@ function createInstance() { let firestore = new Firestore({ projectId: 'test-project', sslCreds: grpc.credentials.createInsecure(), + timestampsInSnapshots: true, }); return firestore._ensureClient().then(() => firestore); diff --git a/test/typescript.ts b/test/typescript.ts index ab394998a..366d24406 100644 --- a/test/typescript.ts +++ b/test/typescript.ts @@ -31,10 +31,16 @@ import DocumentData = FirebaseFirestore.DocumentData; import GeoPoint = FirebaseFirestore.GeoPoint; import Precondition = FirebaseFirestore.Precondition; import SetOptions = FirebaseFirestore.SetOptions; +import Timestamp = FirebaseFirestore.Timestamp; // This test verifies the Typescript typings and is not meant for execution. xdescribe('firestore.d.ts', function() { - const firestore: Firestore = new Firestore(); + const firestore: Firestore = new Firestore({ + keyFilename: 'foo', + projectId: 'foo', + timestampsInSnapshots: true, + otherOption: 'foo' + }); const precondition: Precondition = {lastUpdateTime: "1970-01-01T00:00:00.000Z"}; const setOptions: SetOptions = {merge: true}; @@ -64,8 +70,8 @@ xdescribe('firestore.d.ts', function() { it('has typings for GeoPoint', () => { const geoPoint: GeoPoint = new GeoPoint(90.0, 90.0); - const latitude = geoPoint.latitude; - const longitude = geoPoint.longitude; + const latitude: number = geoPoint.latitude; + const longitude: number = geoPoint.longitude; const equals: boolean = geoPoint.isEqual(geoPoint); }); @@ -303,4 +309,13 @@ xdescribe('firestore.d.ts', function() { const merge: SetOptions = { merge: true}; const mergeFields: SetOptions = { mergeFields: ['foo', fieldPath]}; }); + + it('has typings for Timestamp', () => { + let timestamp: Timestamp = new Timestamp(0,0); + timestamp = Timestamp.now(); + timestamp = Timestamp.fromDate(new Date()); + timestamp = Timestamp.fromMillis(0); + const seconds : number = timestamp.seconds; + const nanoseconds : number = timestamp.nanoseconds; + }); }); diff --git a/test/watch.js b/test/watch.js index 2ba689133..34c6d1b39 100644 --- a/test/watch.js +++ b/test/watch.js @@ -36,6 +36,7 @@ function createInstance() { let firestore = new Firestore({ projectId: 'test-project', sslCreds: grpc.credentials.createInsecure(), + timestampsInSnapshots: true, }); return firestore._ensureClient().then(() => firestore); diff --git a/test/write-batch.js b/test/write-batch.js index d4a362da9..cb2fd387f 100644 --- a/test/write-batch.js +++ b/test/write-batch.js @@ -28,6 +28,7 @@ function createInstance() { let firestore = new Firestore({ projectId: 'test-project', sslCreds: grpc.credentials.createInsecure(), + timestampsInSnapshots: true, }); return firestore._ensureClient().then(() => firestore); diff --git a/types/firestore.d.ts b/types/firestore.d.ts index b75a96e67..f3984232f 100644 --- a/types/firestore.d.ts +++ b/types/firestore.d.ts @@ -39,6 +39,46 @@ declare namespace FirebaseFirestore { */ function setLogFunction(logger: (msg:string) => void): void; + /** + * Settings used to directly configure a `Firestore` instance. + */ + export interface Settings { + /** + * The Firestore Project ID. Can be omitted in environments that support + * `Application Default Credentials` {@see https://cloud.google.com/docs/authentication} + */ + projectId?: string; + + /** + * Local file containing the Service Account credentials. Can be omitted + * in environments that support `Application Default Credentials` + * {@see https://cloud.google.com/docs/authentication} + */ + keyFilename?: string; + + /** + * Enables the use of `Timestamp`s for timestamp fields in + * `DocumentSnapshot`s. + * + * Currently, Firestore returns timestamp fields as `Date` but `Date` only + * supports millisecond precision, which leads to truncation and causes + * unexpected behavior when using a timestamp from a snapshot as a part + * of a subsequent query. + * + * Setting `timestampsInSnapshots` to true will cause Firestore to return + * `Timestamp` values instead of `Date` avoiding this kind of problem. To + * make this work you must also change any code that uses `Date` to use + * `Timestamp` instead. + * + * NOTE: in the future `timestampsInSnapshots: true` will become the + * default and this option will be removed so you should change your code to + * use Timestamp now and opt-in to this new behavior as soon as you can. + */ + timestampsInSnapshots?: boolean; + + [key: string]: any; // Accept other properties, such as GRPC settings. + } + /** * `Firestore` represents a Firestore Database and is the entry point for all * Firestore operations. @@ -48,7 +88,7 @@ declare namespace FirebaseFirestore { * @param options - Configuration object. See [Firestore Documentation] * {@link https://firebase.google.com/docs/firestore/} */ - public constructor(options?: any); + public constructor(options?: Settings); /** * Gets a `CollectionReference` instance that refers to the collection at @@ -1067,6 +1107,91 @@ declare namespace FirebaseFirestore { */ isEqual(other: FieldPath): boolean; } + + /** + * A Timestamp represents a point in time independent of any time zone or + * calendar, represented as seconds and fractions of seconds at nanosecond + * resolution in UTC Epoch time. It is encoded using the Proleptic Gregorian + * Calendar which extends the Gregorian calendar backwards to year one. It is + * encoded assuming all minutes are 60 seconds long, i.e. leap seconds are + * "smeared" so that no leap second table is needed for interpretation. Range + * is from 0001-01-01T00:00:00Z to 9999-12-31T23:59:59.999999999Z. + * + * @see https://github.com/google/protobuf/blob/master/src/google/protobuf/timestamp.proto + */ + export class Timestamp { + /** + * Creates a new timestamp with the current date, with millisecond precision. + * + * @return A new `Timestamp` representing the current date. + */ + static now(): Timestamp; + + /** + * Creates a new timestamp from the given date. + * + * @param date The date to initialize the `Timestamp` from. + * @return A new `Timestamp` representing the same point in time as the + * given date. + */ + static fromDate(date: Date): Timestamp; + + /** + * Creates a new timestamp from the given number of milliseconds. + * + * @param milliseconds Number of milliseconds since Unix epoch + * 1970-01-01T00:00:00Z. + * @return A new `Timestamp` representing the same point in time as the + * given number of milliseconds. + */ + static fromMillis(milliseconds: number): Timestamp; + + /** + * Creates a new timestamp. + * + * @param seconds The number of seconds of UTC time since Unix epoch + * 1970-01-01T00:00:00Z. Must be from 0001-01-01T00:00:00Z to + * 9999-12-31T23:59:59Z inclusive. + * @param nanoseconds The non-negative fractions of a second at nanosecond + * resolution. Negative second values with fractions must still have + * non-negative nanoseconds values that count forward in time. Must be from + * 0 to 999,999,999 inclusive. + */ + constructor(seconds: number, nanoseconds: number); + + /** + * The number of seconds of UTC time since Unix epoch 1970-01-01T00:00:00Z. + */ + readonly seconds: number; + + /** The non-negative fractions of a second at nanosecond resolution. */ + readonly nanoseconds: number; + + /** + * Returns a new `Date` corresponding to this timestamp. This may lose + * precision. + * + * @return JavaScript `Date` object representing the same point in time as + * this `Timestamp`, with millisecond precision. + */ + toDate(): Date; + + /** + * Returns the number of milliseconds since Unix epoch 1970-01-01T00:00:00Z. + * + * @return The point in time corresponding to this timestamp, represented as + * the number of milliseconds since Unix epoch 1970-01-01T00:00:00Z. + */ + toMillis(): number; + + /** + * Returns true if this `Timestamp` is equal to the provided one. + * + * @param other The `Timestamp` to compare against. + * @return 'true' if this `Timestamp` is equal to the provided one. + */ + isEqual(other: Timestamp): boolean; + } } declare module '@google-cloud/firestore' { From fa5c112ced04c722857febf911bb295423e751dd Mon Sep 17 00:00:00 2001 From: Sebastian Schmidt Date: Mon, 25 Jun 2018 13:29:04 -0700 Subject: [PATCH 2/5] Adding no-console --- test/timestamp.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/timestamp.js b/test/timestamp.js index 7eaef52ed..c1ac0b69f 100644 --- a/test/timestamp.js +++ b/test/timestamp.js @@ -93,6 +93,7 @@ describe('timestamps', function() { }); it('converted to dates when disabled', function() { + /* eslint-disable no-console */ const oldErrorLog = console.error; let errorMsg = null; // Prevent error message that prompts to enable `timestampsInSnapshots` @@ -115,6 +116,7 @@ describe('timestamps', function() { console.error = oldErrorLog; }); }); + /* eslint-enable no-console */ }); it('retain seconds and nanoseconds', function() { From e3d913e05b4db70a8681b2a0f6114939d927b103 Mon Sep 17 00:00:00 2001 From: Sebastian Schmidt Date: Mon, 25 Jun 2018 17:25:11 -0700 Subject: [PATCH 3/5] Addressing feedback --- src/index.js | 2 +- src/validate.js | 8 ++++++-- test/document.js | 4 ++-- test/timestamp.js | 4 ++-- types/firestore.d.ts | 2 +- 5 files changed, 12 insertions(+), 8 deletions(-) diff --git a/src/index.js b/src/index.js index 8c00f3bcf..175124ac3 100644 --- a/src/index.js +++ b/src/index.js @@ -216,7 +216,7 @@ class Firestore { * `Timestamp` instead. *
NOTE: in the future `timestampsInSnapshots: true` will become the * default and this option will be removed so you should change your code to - * use Timestamp now and opt-in to this new behavior as soon as you can. + * use `Timestamp` now and opt-in to this new behavior as soon as you can. */ constructor(options) { options = extend({}, options, { diff --git a/src/validate.js b/src/validate.js index b1f78ebdb..c2ffad1a7 100644 --- a/src/validate.js +++ b/src/validate.js @@ -56,7 +56,9 @@ module.exports = validators => { return false; } if (value < min || value > max) { - throw new Error(`Value must be within [${min}, ${max}] inclusive.`); + throw new Error( + `Value must be within [${min}, ${max}] inclusive, but was: ${value}` + ); } return true; }, @@ -67,7 +69,9 @@ module.exports = validators => { return false; } if (value < min || value > max) { - throw new Error(`Value must be within [${min}, ${max}] inclusive.`); + throw new Error( + `Value must be within [${min}, ${max}] inclusive, but was: ${value}` + ); } return true; }, diff --git a/test/document.js b/test/document.js index e09f619d9..d4d116045 100644 --- a/test/document.js +++ b/test/document.js @@ -470,11 +470,11 @@ describe('serialize document', function() { assert.throws(() => { new Firestore.GeoPoint(91, 0); - }, /Argument "latitude" is not a valid number. Value must be within \[-90, 90] inclusive/); + }, /Argument "latitude" is not a valid number. Value must be within \[-90, 90] inclusive, but was: 91/); assert.throws(() => { new Firestore.GeoPoint(90, 181); - }, /Argument "longitude" is not a valid number. Value must be within \[-180, 180] inclusive/); + }, /Argument "longitude" is not a valid number. Value must be within \[-180, 180] inclusive, but was: 181/); }); it('resolves infinite nesting', function() { diff --git a/test/timestamp.js b/test/timestamp.js index c1ac0b69f..1088ffa97 100644 --- a/test/timestamp.js +++ b/test/timestamp.js @@ -209,12 +209,12 @@ describe('timestamps', function() { assert.throws( () => new Firestore.Timestamp(0, -1), - /Argument "nanoseconds" is not a valid integer. Value must be within \[0, 999999999] inclusive/ + /Argument "nanoseconds" is not a valid integer. Value must be within \[0, 999999999] inclusive, but was: -1/ ); assert.throws( () => new Firestore.Timestamp(0, 1000000000), - /Argument "nanoseconds" is not a valid integer. Value must be within \[0, 999999999] inclusive/ + /Argument "nanoseconds" is not a valid integer. Value must be within \[0, 999999999] inclusive, but was: 1000000000/ ); }); }); diff --git a/types/firestore.d.ts b/types/firestore.d.ts index f3984232f..c27798eb7 100644 --- a/types/firestore.d.ts +++ b/types/firestore.d.ts @@ -72,7 +72,7 @@ declare namespace FirebaseFirestore { * * NOTE: in the future `timestampsInSnapshots: true` will become the * default and this option will be removed so you should change your code to - * use Timestamp now and opt-in to this new behavior as soon as you can. + * use `Timestamp` now and opt-in to this new behavior as soon as you can. */ timestampsInSnapshots?: boolean; From 1e193415606a62c2b2829356a81b9a092bb06263 Mon Sep 17 00:00:00 2001 From: Sebastian Schmidt Date: Tue, 26 Jun 2018 09:40:09 -0700 Subject: [PATCH 4/5] Adding examples --- src/timestamp.js | 62 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/src/timestamp.js b/src/timestamp.js index 0b51a569c..8157d42a8 100644 --- a/src/timestamp.js +++ b/src/timestamp.js @@ -41,6 +41,11 @@ class Timestamp { /** * Creates a new timestamp with the current date, with millisecond precision. * + * @example + * let documentRef = firestore.doc('col/doc'); + * + * documentRef.set({ updateTime:Firestore.Timestamp.now() }); + * * @return {Timestamp} A new `Timestamp` representing the current date. */ static now() { @@ -50,6 +55,12 @@ class Timestamp { /** * Creates a new timestamp from the given date. * + * @example + * let documentRef = firestore.doc('col/doc'); + * + * let date = Date.parse('01 Jan 2000 00:00:00 GMT'); + * documentRef.set({ startTime:Firestore.Timestamp.fromDate(date) }); + * * @param {Date} date The date to initialize the `Timestamp` from. * @return {Timestamp} A new `Timestamp` representing the same point in time * as the given date. @@ -61,6 +72,11 @@ class Timestamp { /** * Creates a new timestamp from the given number of milliseconds. * + * @example + * let documentRef = firestore.doc('col/doc'); + * + * documentRef.set({ startTime:Firestore.Timestamp.fromMillis(42) }); + * * @param {number} milliseconds Number of milliseconds since Unix epoch * 1970-01-01T00:00:00Z. * @return {Timestamp} A new `Timestamp` representing the same point in time @@ -75,6 +91,11 @@ class Timestamp { /** * Creates a new timestamp. * + * @example + * let documentRef = firestore.doc('col/doc'); + * + * documentRef.set({ startTime:new Firestore.Timestamp(42, 0) }); + * * @param {number} seconds The number of seconds of UTC time since Unix epoch * 1970-01-01T00:00:00Z. Must be from 0001-01-01T00:00:00Z to * 9999-12-31T23:59:59Z inclusive. @@ -94,6 +115,14 @@ class Timestamp { /** * The number of seconds of UTC time since Unix epoch 1970-01-01T00:00:00Z. * + * @example + * let documentRef = firestore.doc('col/doc'); + * + * documentRef.get().then(snap => { + * let updatedAt = snap.updateTime; + * console.log(`Updated at ${updated.seconds}s ${updated.nanoseconds}ns`); + * }); + * * @type {number} */ get seconds() { @@ -103,6 +132,14 @@ class Timestamp { /** * The non-negative fractions of a second at nanosecond resolution. * + * @example + * let documentRef = firestore.doc('col/doc'); + * + * documentRef.get().then(snap => { + * let updated = snap.updateTime; + * console.log(`Updated at ${updated.seconds}s ${updated.nanoseconds}ns`); + * }); + * * @type {number} */ get nanoseconds() { @@ -113,6 +150,13 @@ class Timestamp { * Returns a new `Date` corresponding to this timestamp. This may lose * precision. * + * @example + * let documentRef = firestore.doc('col/doc'); + * + * documentRef.get().then(snap => { + * console.log(`Document updated at: ${snap.updateTime.toDate()}`); + * }); + * * @return {Date} JavaScript `Date` object representing the same point in time * as this `Timestamp`, with millisecond precision. */ @@ -125,6 +169,15 @@ class Timestamp { /** * Returns the number of milliseconds since Unix epoch 1970-01-01T00:00:00Z. * + * @example + * let documentRef = firestore.doc('col/doc'); + * + * documentRef.get().then(snap => { + * let startTime = snap.get('startTime'); + * let endTime = snap.get('endTime'); + * console.log(`Duration: ${endTime - startTime}`); + * }); + * * @return {number} The point in time corresponding to this timestamp, * represented as the number of milliseconds since Unix epoch * 1970-01-01T00:00:00Z. @@ -136,6 +189,15 @@ class Timestamp { /** * Returns 'true' if this `Timestamp` is equal to the provided one. * + * @example + * let documentRef = firestore.doc('col/doc'); + * + * documentRef.get().then(snap => { + * if (snap.createTime.isEqual(snap.updateTime)) { + * console.log('Document is in its initial state.'); + * } + * }); + * * @param {any} other The `Timestamp` to compare against. * @return {boolean} 'true' if this `Timestamp` is equal to the provided one. */ From b7e722c2298e8c7c6b2c265d3c3f6d74fcfff0eb Mon Sep 17 00:00:00 2001 From: Sebastian Schmidt Date: Tue, 26 Jun 2018 10:42:52 -0700 Subject: [PATCH 5/5] Updating SystemTests --- system-test/firestore.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/system-test/firestore.js b/system-test/firestore.js index c4d9ec875..1d3fcb7db 100644 --- a/system-test/firestore.js +++ b/system-test/firestore.js @@ -172,7 +172,7 @@ describe('DocumentReference class', function() { negativeInfinityValue: -Infinity, objectValue: {foo: 'bar', '😀': '😜'}, emptyObject: {}, - dateValue: new Date('Mar 18, 1985 08:20:00.123 GMT+1000 (CET)'), + dateValue: new Firestore.Timestamp(479978400, 123000000), pathValue: firestore.doc('col1/ref1'), arrayValue: ['foo', 42, 'bar'], emptyArray: [], @@ -238,7 +238,7 @@ describe('DocumentReference class', function() { }) .then(doc => { setTimestamp = doc.get('f'); - assert.ok(is.instanceof(setTimestamp, Date)); + assert.ok(is.instanceof(setTimestamp, Firestore.Timestamp)); assert.deepEqual(doc.data(), { a: 'bar', b: {remove: 'bar'}, @@ -252,7 +252,7 @@ describe('DocumentReference class', function() { }) .then(doc => { let updateTimestamp = doc.get('a'); - assert.ok(is.instanceof(updateTimestamp, Date)); + assert.ok(is.instanceof(updateTimestamp, Firestore.Timestamp)); assert.deepEqual(doc.data(), { a: updateTimestamp, b: {c: updateTimestamp}, @@ -293,7 +293,7 @@ describe('DocumentReference class', function() { .then(() => ref.get()) .then(doc => { let updateTimestamp = doc.get('c'); - assert.ok(is.instanceof(updateTimestamp, Date)); + assert.ok(is.instanceof(updateTimestamp, Firestore.Timestamp)); assert.deepEqual(doc.data(), { a: 'b', c: updateTimestamp,