diff --git a/src/entity.ts b/src/entity.ts index 8d6a1f21e..8e6e2742a 100644 --- a/src/entity.ts +++ b/src/entity.ts @@ -17,7 +17,7 @@ import arrify = require('arrify'); import * as extend from 'extend'; import * as is from 'is'; -import {Query, QueryProto} from './query'; +import {Query, QueryProto, IntegerTypeCastOptions} from './query'; import {PathType} from '.'; import * as Protobuf from 'protobufjs'; import * as path from 'path'; @@ -111,16 +111,36 @@ export namespace entity { * * @class * @param {number|string} value The integer value. + * @param {object} [typeCastOptions] Configuration to convert + * values of `integerValue` type to a custom value. Must provide an + * `integerTypeCastFunction` to handle `integerValue` conversion. + * @param {function} typeCastOptions.integerTypeCastFunction A custom user + * provided function to convert `integerValue`. + * @param {sting|string[]} [typeCastOptions.properties] `Entity` property + * names to be converted using `integerTypeCastFunction`. * * @example * const {Datastore} = require('@google-cloud/datastore'); * const datastore = new Datastore(); * const anInt = datastore.int(7); */ - export class Int { + export class Int extends Number { type: string; value: string; - constructor(value: number | string) { + typeCastFunction?: Function; + typeCastProperties?: string[]; + private _entityPropertyName: string | undefined; + constructor( + value: number | string | ValueProto, + typeCastOptions?: IntegerTypeCastOptions + ) { + super(typeof value === 'object' ? value.integerValue : value); + this._entityPropertyName = + typeof value === 'object' ? value.propertyName : undefined; + this.value = + typeof value === 'object' + ? value.integerValue.toString() + : value.toString(); /** * @name Int#type * @type {string} @@ -130,7 +150,46 @@ export namespace entity { * @name Int#value * @type {string} */ - this.value = value.toString(); + if (typeCastOptions) { + this.typeCastFunction = typeCastOptions.integerTypeCastFunction; + if (typeof typeCastOptions.integerTypeCastFunction !== 'function') { + throw new Error( + `integerTypeCastFunction is not a function or was not provided.` + ); + } + + this.typeCastProperties = typeCastOptions.properties + ? arrify(typeCastOptions.properties) + : undefined; + } + } + // tslint:disable-next-line no-any + valueOf(): any { + let shouldCustomCast = this.typeCastFunction ? true : false; + if ( + this.typeCastProperties && + !this.typeCastProperties.includes(this._entityPropertyName!) + ) { + shouldCustomCast = false; + } + + if (shouldCustomCast) { + try { + return this.typeCastFunction!(this.value); + } catch (error) { + error.message = `integerTypeCastFunction threw an error:\n\n - ${error.message}`; + throw error; + } + } else { + return decodeIntegerValue({ + integerValue: this.value, + propertyName: this._entityPropertyName, + }); + } + } + + toJSON(): Json { + return {type: this.type, value: this.value}; } } @@ -376,11 +435,52 @@ export namespace entity { return value instanceof entity.Key; } + /** + * Convert a protobuf `integerValue`. + * + * @private + * @param {object} value The `integerValue` to convert. + */ + function decodeIntegerValue(value: ValueProto) { + const num = Number(value.integerValue); + if (!Number.isSafeInteger(num)) { + throw new Error( + 'We attempted to return all of the numeric values, but ' + + (value.propertyName ? value.propertyName + ' ' : '') + + 'value ' + + value.integerValue + + " is out of bounds of 'Number.MAX_SAFE_INTEGER'.\n" + + "To prevent this error, please consider passing 'options.wrapNumbers=true' or\n" + + "'options.wrapNumbers' as\n" + + '{\n' + + ' integerTypeCastFunction: provide \n' + + ' properties: optionally specify property name(s) to be cutom casted' + + '}\n' + ); + } + return num; + } + + /** + * @typedef {object} IntegerTypeCastOptions Configuration to convert + * values of `integerValue` type to a custom value. Must provide an + * `integerTypeCastFunction` to handle `integerValue` conversion. + * @property {function} integerTypeCastFunction A custom user + * provided function to convert `integerValue`. + * @property {string | string[]} [properties] `Entity` property + * names to be converted using `integerTypeCastFunction`. + */ /** * Convert a protobuf Value message to its native value. * * @private * @param {object} valueProto The protobuf Value message to convert. + * @param {boolean | IntegerTypeCastOptions} [wrapNumbers=false] Wrap values of integerValue type in + * {@link Datastore#Int} objects. + * If a `boolean`, this will wrap values in {@link Datastore#Int} objects. + * If an `object`, this will return a value returned by + * `wrapNumbers.integerTypeCastFunction`. + * Please see {@link IntegerTypeCastOptions} for options descriptions. * @returns {*} * * @example @@ -399,13 +499,19 @@ export namespace entity { * }); * // */ - export function decodeValueProto(valueProto: ValueProto) { + export function decodeValueProto( + valueProto: ValueProto, + wrapNumbers?: boolean | IntegerTypeCastOptions + ) { const valueType = valueProto.valueType!; const value = valueProto[valueType]; switch (valueType) { case 'arrayValue': { - return value.values.map(entity.decodeValueProto); + // tslint:disable-next-line no-any + return value.values.map((val: any) => + entity.decodeValueProto(val, wrapNumbers) + ); } case 'blobValue': { @@ -421,11 +527,15 @@ export namespace entity { } case 'integerValue': { - return Number(value); + return wrapNumbers + ? typeof wrapNumbers === 'object' + ? new entity.Int(valueProto, wrapNumbers).valueOf() + : new entity.Int(valueProto, undefined) + : decodeIntegerValue(valueProto); } case 'entityValue': { - return entity.entityFromEntityProto(value); + return entity.entityFromEntityProto(value, wrapNumbers); } case 'keyValue': { @@ -554,6 +664,12 @@ export namespace entity { * * @private * @param {object} entityProto The protocol entity object to convert. + * @param {boolean | IntegerTypeCastOptions} [wrapNumbers=false] Wrap values of integerValue type in + * {@link Datastore#Int} objects. + * If a `boolean`, this will wrap values in {@link Datastore#Int} objects. + * If an `object`, this will return a value returned by + * `wrapNumbers.integerTypeCastFunction`. + * Please see {@link IntegerTypeCastOptions} for options descriptions. * @returns {object} * * @example @@ -574,7 +690,10 @@ export namespace entity { * // } */ // tslint:disable-next-line no-any - export function entityFromEntityProto(entityProto: EntityProto): any { + export function entityFromEntityProto( + entityProto: EntityProto, + wrapNumbers?: boolean | IntegerTypeCastOptions + ) { // tslint:disable-next-line no-any const entityObject: any = {}; const properties = entityProto.properties || {}; @@ -582,7 +701,7 @@ export namespace entity { // tslint:disable-next-line forin for (const property in properties) { const value = properties[property]; - entityObject[property] = entity.decodeValueProto(value); + entityObject[property] = entity.decodeValueProto(value, wrapNumbers); } return entityObject; @@ -768,7 +887,12 @@ export namespace entity { * @param {object[]} results The response array. * @param {object} results.entity An entity object. * @param {object} results.entity.key The entity's key. - * @returns {object[]} + * @param {boolean | IntegerTypeCastOptions} [wrapNumbers=false] Wrap values of integerValue type in + * {@link Datastore#Int} objects. + * If a `boolean`, this will wrap values in {@link Datastore#Int} objects. + * If an `object`, this will return a value returned by + * `wrapNumbers.integerTypeCastFunction`. + * Please see {@link IntegerTypeCastOptions} for options descriptions. * * @example * request_('runQuery', {}, (err, response) => { @@ -782,9 +906,12 @@ export namespace entity { * // * }); */ - export function formatArray(results: ResponseResult[]) { + export function formatArray( + results: ResponseResult[], + wrapNumbers?: boolean | IntegerTypeCastOptions + ) { return results.map(result => { - const ent = entity.entityFromEntityProto(result.entity!); + const ent = entity.entityFromEntityProto(result.entity!, wrapNumbers); ent[entity.KEY_SYMBOL] = entity.keyFromKeyProto(result.entity!.key!); return ent; }); @@ -1274,6 +1401,7 @@ export interface ValueProto { values?: ValueProto[]; // tslint:disable-next-line no-any value?: any; + propertyName?: string; } export interface EntityProto { @@ -1305,3 +1433,7 @@ export interface EntityObject { data: {[k: string]: Entity}; excludeFromIndexes: string[]; } + +export interface Json { + [field: string]: string; +} diff --git a/src/query.ts b/src/query.ts index 521f9d0a0..38147176f 100644 --- a/src/query.ts +++ b/src/query.ts @@ -397,6 +397,14 @@ class Query { * If not specified, default values are chosen by Datastore for the * operation. Learn more about strong and eventual consistency * [here](https://cloud.google.com/datastore/docs/articles/balancing-strong-and-eventual-consistency-with-google-cloud-datastore). + * @param {object} [options.gaxOptions] Request configuration options, outlined + * here: https://googleapis.github.io/gax-nodejs/global.html#CallOptions. + * @param {boolean | IntegerTypeCastOptions} [options.wrapNumbers=false] + * Wrap values of integerValue type in {@link Datastore#Int} objects. + * If a `boolean`, this will wrap values in {@link Datastore#Int} objects. + * If an `object`, this will return a value returned by + * `wrapNumbers.integerTypeCastFunction`. + * Please see {@link IntegerTypeCastOptions} for options descriptions. * @param {function} [callback] The callback function. If omitted, a readable * stream instance is returned. * @param {?error} callback.err An error returned while making this request @@ -517,9 +525,15 @@ export interface QueryProto { */ export {Query}; +export interface IntegerTypeCastOptions { + integerTypeCastFunction: Function; + properties?: string | string[]; +} + export interface RunQueryOptions { consistency?: 'strong' | 'eventual'; gaxOptions?: CallOptions; + wrapNumbers?: boolean | IntegerTypeCastOptions; } export interface RunQueryCallback { diff --git a/src/request.ts b/src/request.ts index adbb63f3b..c06f50cf6 100644 --- a/src/request.ts +++ b/src/request.ts @@ -290,7 +290,10 @@ class DatastoreRequest { return; } - const entities = entity.formatArray(resp!.found! as ResponseResult[]); + const entities = entity.formatArray( + resp!.found! as ResponseResult[], + options.wrapNumbers + ); const nextKeys = (resp!.deferred || []) .map(entity.keyFromKeyProto) .map(entity.keyToKeyProto); @@ -432,6 +435,12 @@ class DatastoreRequest { * [here](https://cloud.google.com/datastore/docs/articles/balancing-strong-and-eventual-consistency-with-google-cloud-datastore). * @param {object} [options.gaxOptions] Request configuration options, outlined * here: https://googleapis.github.io/gax-nodejs/global.html#CallOptions. + * @param {boolean | IntegerTypeCastOptions} [options.wrapNumbers=false] + * Wrap values of integerValue type in {@link Datastore#Int} objects. + * If a `boolean`, this will wrap values in {@link Datastore#Int} objects. + * If an `object`, this will return a value returned by + * `wrapNumbers.integerTypeCastFunction`. + * Please see {@link IntegerTypeCastOptions} for options descriptions. * @param {function} callback The callback function. * @param {?error} callback.err An error returned while making this request * @param {object|object[]} callback.entity The entity object(s) which match @@ -571,7 +580,6 @@ class DatastoreRequest { * that uses the end cursor from the previous query as the starting cursor for * the next query. You can pass that object back to this method to see if more * results exist. - * * @param {Query} query Query object. * @param {object} [options] Optional configuration. * @param {string} [options.consistency] Specify either `strong` or `eventual`. @@ -580,6 +588,12 @@ class DatastoreRequest { * [here](https://cloud.google.com/datastore/docs/articles/balancing-strong-and-eventual-consistency-with-google-cloud-datastore). * @param {object} [options.gaxOptions] Request configuration options, outlined * here: https://googleapis.github.io/gax-nodejs/global.html#CallOptions. + * @param {boolean | IntegerTypeCastOptions} [options.wrapNumbers=false] + * Wrap values of integerValue type in {@link Datastore#Int} objects. + * If a `boolean`, this will wrap values in {@link Datastore#Int} objects. + * If an `object`, this will return a value returned by + * `wrapNumbers.integerTypeCastFunction`. + * Please see {@link IntegerTypeCastOptions} for options descriptions. * @param {function} [callback] The callback function. If omitted, a readable * stream instance is returned. * @param {?error} callback.err An error returned while making this request @@ -764,7 +778,10 @@ class DatastoreRequest { let entities: Entity[] = []; if (resp.batch.entityResults) { - entities = entity.formatArray(resp.batch.entityResults); + entities = entity.formatArray( + resp.batch.entityResults, + options.wrapNumbers + ); } // Emit each result right away, then get the rest if necessary. @@ -1400,10 +1417,7 @@ export interface AllocateIdsOptions { allocations?: number; gaxOptions?: CallOptions; } -export interface CreateReadStreamOptions { - consistency?: string; - gaxOptions?: CallOptions; -} +export interface CreateReadStreamOptions extends RunQueryOptions {} export interface GetCallback { (err?: Error | null, entity?: Entities): void; } diff --git a/test/entity.ts b/test/entity.ts index 8baa2dacf..beb5f72e7 100644 --- a/test/entity.ts +++ b/test/entity.ts @@ -16,8 +16,11 @@ import * as assert from 'assert'; import * as extend from 'extend'; +import * as sinon from 'sinon'; import {Datastore} from '../src'; -import {Entity, entity} from '../src/entity'; +import {Entity, entity, ValueProto} from '../src/entity'; +import {IntegerTypeCastOptions} from '../src/query'; +import {AnyARecord} from 'dns'; describe('entity', () => { let entity: Entity; @@ -79,6 +82,151 @@ describe('entity', () => { const int = new entity.Int(value); assert.strictEqual(int.value, value.toString()); }); + + it('should store the stringified value from valueProto object', () => { + const valueProto = { + valueType: 'integerValue', + integerValue: 8, + }; + const int = new entity.Int(valueProto); + assert.strictEqual(int.value, valueProto.integerValue.toString()); + }); + + describe('valueOf', () => { + let valueProto: {}; + beforeEach(() => { + valueProto = { + valueType: 'integerValue', + integerValue: 8, + }; + }); + + describe('integerTypeCastFunction is not provided', () => { + const expectedError = (opts: { + integerValue?: number; + propertyName?: string; + }) => { + return new Error( + 'We attempted to return all of the numeric values, but ' + + (opts.propertyName ? opts.propertyName + ' ' : '') + + 'value ' + + opts.integerValue + + " is out of bounds of 'Number.MAX_SAFE_INTEGER'.\n" + + "To prevent this error, please consider passing 'options.wrapNumbers=true' or\n" + + "'options.wrapNumbers' as\n" + + '{\n' + + ' integerTypeCastFunction: provide \n' + + ' properties: optionally specify property name(s) to be cutom casted' + + '}\n' + ); + }; + it('should throw if integerTypeCastOptions is provided but integerTypeCastFunction is not', () => { + assert.throws( + () => new entity.Int(valueProto, {}).valueOf(), + /integerTypeCastFunction is not a function or was not provided\./ + ); + }); + + it('should throw if integer value is outside of bounds passing objects', () => { + const largeIntegerValue = Number.MAX_SAFE_INTEGER + 1; + const smallIntegerValue = Number.MIN_SAFE_INTEGER - 1; + + const valueProto = { + integerValue: largeIntegerValue, + propertyName: 'phoneNumber', + }; + + const valueProto2 = { + integerValue: smallIntegerValue, + propertyName: 'phoneNumber', + }; + + assert.throws(() => { + new entity.Int(valueProto).valueOf(); + }, expectedError(valueProto)); + + assert.throws(() => { + new entity.Int(valueProto2).valueOf(); + }, expectedError(valueProto2)); + }); + + it('should throw if integer value is outside of bounds passing strings or Numbers', () => { + const largeIntegerValue = Number.MAX_SAFE_INTEGER + 1; + const smallIntegerValue = Number.MIN_SAFE_INTEGER - 1; + + // should throw when Number is passed + assert.throws(() => { + new entity.Int(largeIntegerValue).valueOf(); + }, expectedError({integerValue: largeIntegerValue})); + + // should throw when string is passed + assert.throws(() => { + new entity.Int(smallIntegerValue.toString()).valueOf(); + }, expectedError({integerValue: smallIntegerValue})); + }); + + it('should not auto throw on initialization', () => { + const largeIntegerValue = Number.MAX_SAFE_INTEGER + 1; + + const valueProto = { + valueType: 'integerValue', + integerValue: largeIntegerValue, + }; + + assert.doesNotThrow(() => { + const a = new entity.Int(valueProto); + }, new RegExp(`Integer value ${largeIntegerValue} is out of bounds.`)); + }); + }); + + describe('integerTypeCastFunction is provided', () => { + it('should throw if integerTypeCastFunction is not a function', () => { + assert.throws( + () => + new entity.Int(valueProto, { + integerTypeCastFunction: {}, + }).valueOf(), + /integerTypeCastFunction is not a function or was not provided\./ + ); + }); + + it('should custom-cast integerValue when integerTypeCastFunction is provided', () => { + const stub = sinon.stub(); + + new entity.Int(valueProto, { + integerTypeCastFunction: stub, + }).valueOf(); + assert.ok(stub.calledOnce); + }); + + it('should custom-cast integerValue if `properties` specified by user', () => { + const stub = sinon.stub(); + Object.assign(valueProto, { + propertyName: 'thisValue', + }); + + new entity.Int(valueProto, { + integerTypeCastFunction: stub, + properties: 'thisValue', + }).valueOf(); + assert.ok(stub.calledOnce); + }); + + it('should not custom-cast integerValue if `properties` not specified by user', () => { + const stub = sinon.stub(); + + Object.assign(valueProto, { + propertyName: 'thisValue', + }); + + new entity.Int(valueProto, { + integerTypeCastFunction: stub, + properties: 'thatValue', + }).valueOf(); + assert.ok(stub.notCalled); + }); + }); + }); }); describe('isDsInt', () => { @@ -199,9 +347,9 @@ describe('entity', () => { 'ParentKind', 'name', 'Kind', - new entity.Int(1).valueOf(), + new entity.Int(1), 'SubKind', - new entity.Int('1').valueOf(), + new entity.Int('1'), ], }); }); @@ -237,9 +385,14 @@ describe('entity', () => { }); describe('decodeValueProto', () => { - it('should decode arrays', () => { - const expectedValue = [{}]; - + describe('arrays', () => { + const intValue = 8; + const expectedValue = [ + { + valueType: 'integerValue', + integerValue: intValue, + }, + ]; const valueProto = { valueType: 'arrayValue', arrayValue: { @@ -247,23 +400,259 @@ describe('entity', () => { }, }; - let run = false; + it('should decode arrays', () => { + const expectedValue = [{}]; - const decodeValueProto = entity.decodeValueProto; - entity.decodeValueProto = (valueProto: {}) => { - if (!run) { - run = true; - return decodeValueProto(valueProto); - } + const valueProto = { + valueType: 'arrayValue', + arrayValue: { + values: expectedValue, + }, + }; + + let run = false; + + const decodeValueProto = entity.decodeValueProto; + entity.decodeValueProto = (valueProto: {}) => { + if (!run) { + run = true; + return decodeValueProto(valueProto); + } + + assert.strictEqual(valueProto, expectedValue[0]); + return valueProto; + }; + + assert.deepStrictEqual( + entity.decodeValueProto(valueProto), + expectedValue + ); + }); + + it('should not wrap numbers by default', () => { + const decodeValueProto = entity.decodeValueProto; + entity.decodeValueProto = ( + valueProto: {}, + wrapNumbers?: boolean | {} + ) => { + assert.strictEqual(wrapNumbers, undefined); + + return decodeValueProto(valueProto, wrapNumbers); + }; + + assert.deepStrictEqual(entity.decodeValueProto(valueProto), [intValue]); + }); + + it('should wrap numbers with an option', () => { + const wrapNumbersBoolean = true; + const wrapNumbersObject = {}; + const decodeValueProto = entity.decodeValueProto; + let run = false; + entity.decodeValueProto = ( + valueProto: {}, + wrapNumbers?: boolean | {} + ) => { + if (!run) { + run = true; + return decodeValueProto(valueProto, wrapNumbers); + } + + // verify that `wrapNumbers`param is passed (boolean or object) + assert.ok(wrapNumbers); + return valueProto; + }; + + assert.deepStrictEqual( + entity.decodeValueProto(valueProto, wrapNumbersBoolean), + expectedValue + ); + + // reset the run flag. + run = false; + assert.deepStrictEqual( + entity.decodeValueProto(valueProto, wrapNumbersObject), + expectedValue + ); + }); + }); + + describe('entities', () => { + it('should decode entities', () => { + const expectedValue = {}; + + const valueProto = { + valueType: 'entityValue', + entityValue: expectedValue, + }; + + entity.entityFromEntityProto = (entityProto: {}) => { + assert.strictEqual(entityProto, expectedValue); + return expectedValue; + }; + + assert.strictEqual(entity.decodeValueProto(valueProto), expectedValue); + }); + + it('should not wrap numbers by default', () => { + const expectedValue = {}; + + const valueProto = { + valueType: 'entityValue', + entityValue: expectedValue, + }; + + entity.entityFromEntityProto = ( + entityProto: {}, + wrapNumbers?: boolean | {} + ) => { + assert.strictEqual(wrapNumbers, undefined); + assert.strictEqual(entityProto, expectedValue); + return expectedValue; + }; - assert.strictEqual(valueProto, expectedValue[0]); - return valueProto; + assert.strictEqual(entity.decodeValueProto(valueProto), expectedValue); + }); + + it('should wrap numbers with an option', () => { + const expectedValue = {}; + const wrapNumbersBoolean = true; + const wrapNumbersObject = {}; + + const valueProto = { + valueType: 'entityValue', + entityValue: expectedValue, + }; + + entity.entityFromEntityProto = ( + entityProto: {}, + wrapNumbers?: boolean | {} + ) => { + // verify that `wrapNumbers`param is passed (boolean or object) + assert.ok(wrapNumbers); + assert.strictEqual(entityProto, expectedValue); + return expectedValue; + }; + + assert.strictEqual( + entity.decodeValueProto(valueProto, wrapNumbersBoolean), + expectedValue + ); + + assert.strictEqual( + entity.decodeValueProto(valueProto, wrapNumbersObject), + expectedValue + ); + }); + }); + + describe('integerValues', () => { + const valueProto = { + valueType: 'integerValue', + integerValue: 8, }; - assert.deepStrictEqual( - entity.decodeValueProto(valueProto), - expectedValue - ); + describe('default `wrapNumbers: undefined`', () => { + it('should not wrap ints by default', () => { + assert.strictEqual( + typeof entity.decodeValueProto(valueProto), + 'number' + ); + }); + + it('should throw if integer value is outside of bounds', () => { + const expectedError = (opts: { + integerValue: number; + propertyName: string; + }) => { + return new Error( + 'We attempted to return all of the numeric values, but ' + + (opts.propertyName ? opts.propertyName + ' ' : '') + + 'value ' + + opts.integerValue + + " is out of bounds of 'Number.MAX_SAFE_INTEGER'.\n" + + "To prevent this error, please consider passing 'options.wrapNumbers=true' or\n" + + "'options.wrapNumbers' as\n" + + '{\n' + + ' integerTypeCastFunction: provide \n' + + ' properties: optionally specify property name(s) to be cutom casted' + + '}\n' + ); + }; + const largeIntegerValue = Number.MAX_SAFE_INTEGER + 1; + const smallIntegerValue = Number.MIN_SAFE_INTEGER - 1; + + const valueProto = { + valueType: 'integerValue', + integerValue: largeIntegerValue, + propertyName: 'phoneNumber', + }; + + const valueProto2 = { + valueType: 'integerValue', + integerValue: smallIntegerValue, + propertyName: 'phoneNumber', + }; + + assert.throws(() => { + entity.decodeValueProto(valueProto); + }, expectedError(valueProto)); + + assert.throws(() => { + entity.decodeValueProto(valueProto2); + }, expectedError(valueProto2)); + }); + }); + + describe('should wrap ints with option', () => { + it('should wrap ints with wrapNumbers as boolean', () => { + const wrapNumbers = true; + const stub = sinon.spy(entity, 'Int'); + + entity.decodeValueProto(valueProto, wrapNumbers); + assert.strictEqual(stub.called, true); + }); + + it('should wrap ints with wrapNumbers as object', () => { + const wrapNumbers = {integerTypeCastFunction: () => {}}; + const stub = sinon.spy(entity, 'Int'); + + entity.decodeValueProto(valueProto, wrapNumbers); + assert.strictEqual(stub.called, true); + }); + + it('should call #valueOf if integerTypeCastFunction is provided', () => { + Object.assign(valueProto, {integerValue: Number.MAX_SAFE_INTEGER}); + const takeFirstTen = sinon + .stub() + .callsFake((value: string) => value.toString().substr(0, 10)); + const wrapNumbers = {integerTypeCastFunction: takeFirstTen}; + + assert.strictEqual( + entity.decodeValueProto(valueProto, wrapNumbers), + takeFirstTen(Number.MAX_SAFE_INTEGER) + ); + assert.strictEqual(takeFirstTen.called, true); + }); + + it('should propagate error from typeCastfunction', () => { + const errorMessage = 'some error from type casting function'; + const error = new Error(errorMessage); + const stub = sinon.stub().throws(error); + assert.throws( + () => + entity + .decodeValueProto(valueProto, { + integerTypeCastFunction: stub, + }) + .valueOf(), + (err: Error) => { + return new RegExp( + `integerTypeCastFunction threw an error:\n\n - ${errorMessage}` + ).test(err.message); + } + ); + }); + }); }); it('should decode blobs', () => { @@ -303,33 +692,6 @@ describe('entity', () => { assert.strictEqual(entity.decodeValueProto(valueProto), expectedValue); }); - it('should decode ints', () => { - const expectedValue = 8; - - const valueProto = { - valueType: 'integerValue', - integerValue: expectedValue, - }; - - assert.strictEqual(entity.decodeValueProto(valueProto), expectedValue); - }); - - it('should decode entities', () => { - const expectedValue = {}; - - const valueProto = { - valueType: 'entityValue', - entityValue: expectedValue, - }; - - entity.entityFromEntityProto = (entityProto: {}) => { - assert.strictEqual(entityProto, expectedValue); - return expectedValue; - }; - - assert.strictEqual(entity.decodeValueProto(valueProto), expectedValue); - }); - it('should decode keys', () => { const expectedValue = {}; @@ -623,6 +985,44 @@ describe('entity', () => { expectedEntity ); }); + + describe('should pass `wrapNumbers` to decodeValueProto', () => { + const entityProto = {properties: {number: {}}}; + let decodeValueProtoStub: sinon.SinonStub; + let wrapNumbers: boolean | IntegerTypeCastOptions | undefined; + + beforeEach(() => { + decodeValueProtoStub = sinon.stub(entity, 'decodeValueProto'); + }); + + afterEach(() => { + decodeValueProtoStub.restore(); + }); + + it('should pass `wrapNumbers` to decodeValueProto as undefined by default', () => { + entity.entityFromEntityProto(entityProto); + wrapNumbers = decodeValueProtoStub.getCall(0).args[1]; + assert.strictEqual(wrapNumbers, undefined); + }); + + it('should pass `wrapNumbers` to decodeValueProto as boolean', () => { + entity.entityFromEntityProto(entityProto, true); + wrapNumbers = decodeValueProtoStub.getCall(0).args[1]; + assert.strictEqual(typeof wrapNumbers, 'boolean'); + }); + + it('should pass `wrapNumbers` to decodeValueProto as IntegerTypeCastOptions', () => { + const integerTypeCastOptions = { + integerTypeCastFunction: () => {}, + properties: 'that', + }; + + entity.entityFromEntityProto(entityProto, integerTypeCastOptions); + wrapNumbers = decodeValueProtoStub.getCall(0).args[1]; + assert.strictEqual(wrapNumbers, integerTypeCastOptions); + assert.deepStrictEqual(wrapNumbers, integerTypeCastOptions); + }); + }); }); describe('entityToEntityProto', () => { @@ -1110,6 +1510,48 @@ describe('entity', () => { assert.deepStrictEqual(ent, expectedResults); assert.strictEqual(ent[entity.KEY_SYMBOL], key); }); + + describe('should pass `wrapNumbers` to entityFromEntityProto', () => { + const results = [{entity: {}}]; + // tslint:disable-next-line no-any + let entityFromEntityProtoStub: any; + let wrapNumbers: boolean | IntegerTypeCastOptions | undefined; + + beforeEach(() => { + entityFromEntityProtoStub = sinon + .stub(entity, 'entityFromEntityProto') + .callsFake(() => ({})); + sinon.stub(entity, 'keyFromKeyProto'); + }); + + afterEach(() => { + entityFromEntityProtoStub.restore(); + }); + + it('should pass `wrapNumbers` to entityFromEntityProto as undefined by default', () => { + entity.formatArray(results); + wrapNumbers = entityFromEntityProtoStub.getCall(0).args[1]; + assert.strictEqual(wrapNumbers, undefined); + }); + + it('should pass `wrapNumbers` to entityFromEntityProto as boolean', () => { + entity.formatArray(results, true); + wrapNumbers = entityFromEntityProtoStub.getCall(0).args[1]; + assert.strictEqual(typeof wrapNumbers, 'boolean'); + }); + + it('should pass `wrapNumbers` to entityFromEntityProto as IntegerTypeCastOptions', () => { + const integerTypeCastOptions = { + integerTypeCastFunction: () => {}, + properties: 'that', + }; + + entity.formatArray(results, integerTypeCastOptions); + wrapNumbers = entityFromEntityProtoStub.getCall(0).args[1]; + assert.strictEqual(wrapNumbers, integerTypeCastOptions); + assert.deepStrictEqual(wrapNumbers, integerTypeCastOptions); + }); + }); }); describe('isKeyComplete', () => { diff --git a/test/request.ts b/test/request.ts index 17d5ee05c..eb67aa2db 100644 --- a/test/request.ts +++ b/test/request.ts @@ -26,7 +26,7 @@ import {Transform} from 'stream'; import {google} from '../proto/datastore'; import * as ds from '../src'; import {entity, Entity, KeyProto, EntityProto} from '../src/entity.js'; -import {Query, QueryProto} from '../src/query.js'; +import {IntegerTypeCastOptions, Query, QueryProto} from '../src/query.js'; import { AllocateIdsResponse, RequestConfig, @@ -456,6 +456,69 @@ describe('Request', () => { .emit('reading'); }); + describe('should pass `wrapNumbers` to formatArray', () => { + let wrapNumbersOpts: boolean | IntegerTypeCastOptions | undefined; + let formtArrayStub: Any; + + beforeEach(() => { + formtArrayStub = sandbox + .stub(entity, 'formatArray') + .callsFake(arr => { + assert.strictEqual(arr, apiResponse.found); + return arr; + }); + }); + + afterEach(() => { + formtArrayStub.restore(); + }); + + it('should pass `wrapNumbers` to formatArray as undefined by default', done => { + request + .createReadStream(key) + .on('error', done) + .resume(); + + setImmediate(() => { + wrapNumbersOpts = formtArrayStub.getCall(0).args[1]; + assert.strictEqual(wrapNumbersOpts, undefined); + done(); + }); + }); + + it('should pass `wrapNumbers` to formatArray as bolean', done => { + request + .createReadStream(key, {wrapNumbers: true}) + .on('error', done) + .resume(); + + setImmediate(() => { + wrapNumbersOpts = formtArrayStub.getCall(0).args[1]; + assert.strictEqual(typeof wrapNumbersOpts, 'boolean'); + done(); + }); + }); + + it('should pass `wrapNumbers` to formatArray as IntegerTypeCastOptions', done => { + const integerTypeCastOptions = { + integerTypeCastFunction: () => {}, + properties: 'that', + }; + + request + .createReadStream(key, {wrapNumbers: integerTypeCastOptions}) + .on('error', done) + .resume(); + + setImmediate(() => { + wrapNumbersOpts = formtArrayStub.getCall(0).args[1]; + assert.strictEqual(wrapNumbersOpts, integerTypeCastOptions); + assert.deepStrictEqual(wrapNumbersOpts, integerTypeCastOptions); + done(); + }); + }); + }); + it('should continue looking for deferred results', done => { let numTimesCalled = 0; @@ -646,6 +709,61 @@ describe('Request', () => { done(); }); }); + + describe('should pass `wrapNumbers` to createReadStream', () => { + it('should pass `wrapNumbers` to createReadStream as undefined by default', done => { + request.get(keys, (err: Error) => { + assert.ifError(err); + + const createReadStreamOptions = request.createReadStream.getCall(0) + .args[1]; + assert.strictEqual(createReadStreamOptions.wrapNumbers, undefined); + done(); + }); + }); + + it('should pass `wrapNumbers` to createReadStream as boolean', done => { + request.get(keys, {wrapNumbers: true}, (err: Error) => { + assert.ifError(err); + + const createReadStreamOptions = request.createReadStream.getCall(0) + .args[1]; + assert.strictEqual( + typeof createReadStreamOptions.wrapNumbers, + 'boolean' + ); + done(); + }); + }); + + it('should pass `wrapNumbers` to createReadStream as IntegerTypeCastOptions', done => { + const integerTypeCastOptions = { + integerTypeCastFunction: () => {}, + properties: 'that', + }; + + request.get( + keys, + {wrapNumbers: integerTypeCastOptions}, + (err: Error) => { + assert.ifError(err); + + const createReadStreamOptions = request.createReadStream.getCall( + 0 + ).args[1]; + assert.strictEqual( + createReadStreamOptions.wrapNumbers, + integerTypeCastOptions + ); + assert.deepStrictEqual( + createReadStreamOptions.wrapNumbers, + integerTypeCastOptions + ); + done(); + } + ); + }); + }); }); describe('error', () => { @@ -884,6 +1002,65 @@ describe('Request', () => { }); }); + describe('should pass `wrapNumbers` to formatArray', () => { + let wrapNumbersOpts: boolean | IntegerTypeCastOptions | undefined; + + beforeEach(() => { + sandbox.stub(entity, 'queryToQueryProto'); + formatArrayStub.restore(); + formatArrayStub = sandbox + .stub(entity, 'formatArray') + .callsFake((array, wrapNumbers) => { + return array; + }); + }); + + it('should pass `wrapNumbers` to formatArray as undefined by default', done => { + request + .runQueryStream({}) + .on('error', assert.ifError) + .resume(); + + setImmediate(() => { + wrapNumbersOpts = formatArrayStub.getCall(0).args[1]; + assert.strictEqual(wrapNumbersOpts, undefined); + done(); + }); + }); + + it('should pass `wrapNumbers` to formatArray as boolean', done => { + request + .runQueryStream({}, {wrapNumbers: true}) + .on('error', assert.ifError) + .resume(); + + setImmediate(() => { + wrapNumbersOpts = formatArrayStub.getCall(0).args[1]; + assert.strictEqual(typeof wrapNumbersOpts, 'boolean'); + done(); + }); + }); + + it('should pass `wrapNumbers` to formatArray as IntegerTypeCastOptions', done => { + const integerTypeCastOptions = { + integerTypeCastFunction: () => {}, + properties: 'that', + }; + + request + .runQueryStream({}, {wrapNumbers: integerTypeCastOptions}) + .on('error', assert.ifError) + .resume(); + + setImmediate(() => { + wrapNumbersOpts = formatArrayStub.getCall(0).args[1]; + assert.strictEqual(wrapNumbersOpts, integerTypeCastOptions); + assert.deepStrictEqual(wrapNumbersOpts, integerTypeCastOptions); + done(); + }); + }); + }); + it('should re-run query if not finished', done => { const query = { limitVal: 1, @@ -1125,6 +1302,54 @@ describe('Request', () => { ); }); + describe('should pass `wrapNumbers` to runQueryStream', () => { + it('should pass `wrapNumbers` to runQueryStream as undefined by default', done => { + request.runQuery(query, (err: Error) => { + assert.ifError(err); + + const runQueryOptions = request.runQueryStream.getCall(0).args[1]; + assert.strictEqual(runQueryOptions.wrapNumbers, undefined); + done(); + }); + }); + + it('should pass `wrapNumbers` to runQueryStream boolean', done => { + request.runQuery(query, {wrapNumbers: true}, (err: Error) => { + assert.ifError(err); + + const runQueryOptions = request.runQueryStream.getCall(0).args[1]; + assert.strictEqual(typeof runQueryOptions.wrapNumbers, 'boolean'); + done(); + }); + }); + + it('should pass `wrapNumbers` to runQueryStream as IntegerTypeCastOptions', done => { + const integerTypeCastOptions = { + integerTypeCastFunction: () => {}, + properties: 'that', + }; + + request.runQuery( + query, + {wrapNumbers: integerTypeCastOptions}, + (err: Error) => { + assert.ifError(err); + + const runQueryOptions = request.runQueryStream.getCall(0).args[1]; + assert.strictEqual( + runQueryOptions.wrapNumbers, + integerTypeCastOptions + ); + assert.deepStrictEqual( + runQueryOptions.wrapNumbers, + integerTypeCastOptions + ); + done(); + } + ); + }); + }); + it('should allow options to be omitted', done => { request.runQuery(query, (err: Error) => { assert.ifError(err);