diff --git a/src/entity.ts b/src/entity.ts index 56d5b1109..8107e41e2 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 {Entities} from './request'; import * as Protobuf from 'protobufjs'; @@ -327,11 +327,68 @@ export namespace entity { return value instanceof entity.Key; } + /** + * Convert a protobuf `integerValue`. + * + * @private + * @param {object} valueProto The protobuf `integerValue` to convert. + * @param {object} integerTypeCastOptions Config for custom `integerValue` cast. + * @param {function} [integerTypeCastOptions.integerTypeCastFunction] A custom user + * provided function to convert `integerValue`. + * @param {sting|string[]} [integerTypeCastOptions.names] `Entity` property + * names to be converted using `integerTypeCastFunction`. + */ + function decodeIntegerValue( + valueProto: ValueProto, + integerTypeCastOptions?: IntegerTypeCastOptions + ) { + let customCast = false; + if (integerTypeCastOptions) { + if ( + typeof integerTypeCastOptions.integerTypeCastFunction !== 'function' + ) { + throw new Error( + `integerTypeCastFunction is not a function or was not provided.` + ); + } + customCast = true; + if (integerTypeCastOptions.names) { + integerTypeCastOptions.names = arrify(integerTypeCastOptions.names); + if (!integerTypeCastOptions.names.includes(valueProto.name!)) { + customCast = false; + } + } + } + + if (customCast) { + try { + return integerTypeCastOptions!.integerTypeCastFunction( + valueProto.integerValue + ); + } catch (error) { + throw new Error(`integerTypeCastFunction threw an error:\n${error}`); + } + } else { + const num = Number(valueProto[`integerValue`]); + if (!Number.isSafeInteger(num)) { + throw new Error( + `Integer value ${valueProto[`integerValue`]} is out of bounds.` + ); + } + return num; + } + } + /** * Convert a protobuf Value message to its native value. * * @private * @param {object} valueProto The protobuf Value message to convert. + * @param {object} integerTypeCastOptions Config for custom `integerValue` cast. + * @param {function} [integerTypeCastOptions.integerTypeCastFunction] A custom user + * provided function to convert `integerValue`. + * @param {sting|string[]} [integerTypeCastOptions.names] `Entity` property + * names to be converted using `integerTypeCastFunction`. * @returns {*} * * @example @@ -350,13 +407,19 @@ export namespace entity { * }); * // */ - export function decodeValueProto(valueProto: ValueProto) { + export function decodeValueProto( + valueProto: ValueProto, + integerTypeCastOptions?: 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, integerTypeCastOptions) + ); } case 'blobValue': { @@ -372,7 +435,7 @@ export namespace entity { } case 'integerValue': { - return Number(value); + return decodeIntegerValue(valueProto, integerTypeCastOptions); } case 'entityValue': { @@ -505,6 +568,11 @@ export namespace entity { * * @private * @param {object} entityProto The protocol entity object to convert. + * @param {object} integerTypeCastOptions Config for custom `integerValue` cast. + * @param {function} [integerTypeCastOptions.integerTypeCastFunction] A custom user + * provided function to convert `integerValue`. + * @param {sting|string[]} [integerTypeCastOptions.names] `Entity` property + * names to be converted using `integerTypeCastFunction`. * @returns {object} * * @example @@ -525,7 +593,10 @@ export namespace entity { * // } */ // tslint:disable-next-line no-any - export function entityFromEntityProto(entityProto: EntityProto): any { + export function entityFromEntityProto( + entityProto: EntityProto, + integerTypeCastOptions?: IntegerTypeCastOptions + ) { // tslint:disable-next-line no-any const entityObject: any = {}; const properties = entityProto.properties || {}; @@ -533,7 +604,11 @@ export namespace entity { // tslint:disable-next-line forin for (const property in properties) { const value = properties[property]; - entityObject[property] = entity.decodeValueProto(value); + value.name = property; + entityObject[property] = entity.decodeValueProto( + value, + integerTypeCastOptions + ); } return entityObject; @@ -719,6 +794,11 @@ 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. + * @param {object} integerTypeCastOptions Config for custom `integerValue` cast. + * @param {function} [integerTypeCastOptions.integerTypeCastFunction] A custom user + * provided function to convert `integerValue`. + * @param {sting|string[]} [integerTypeCastOptions.names] `Entity` property + * names to be converted using `integerTypeCastFunction`. * @returns {object[]} * * @example @@ -733,9 +813,15 @@ export namespace entity { * // * }); */ - export function formatArray(results: ResponseResult[]) { + export function formatArray( + results: ResponseResult[], + integerTypeCastOptions?: IntegerTypeCastOptions + ) { return results.map(result => { - const ent = entity.entityFromEntityProto(result.entity!); + const ent = entity.entityFromEntityProto( + result.entity!, + integerTypeCastOptions + ); ent[entity.KEY_SYMBOL] = entity.keyFromKeyProto(result.entity!.key!); return ent; }); @@ -1225,6 +1311,7 @@ export interface ValueProto { values?: ValueProto[]; // tslint:disable-next-line no-any value?: any; + name?: string; } export interface EntityProto { diff --git a/src/query.ts b/src/query.ts index 521f9d0a0..225b7329b 100644 --- a/src/query.ts +++ b/src/query.ts @@ -517,9 +517,15 @@ export interface QueryProto { */ export {Query}; +export interface IntegerTypeCastOptions { + integerTypeCastFunction: Function; + names?: string | string[]; +} + export interface RunQueryOptions { consistency?: 'strong' | 'eventual'; gaxOptions?: CallOptions; + integerTypeCastOptions?: IntegerTypeCastOptions; } export interface RunQueryCallback { diff --git a/src/request.ts b/src/request.ts index 7b57bdbe6..ba1fba144 100644 --- a/src/request.ts +++ b/src/request.ts @@ -289,7 +289,10 @@ class DatastoreRequest { return; } - const entities = entity.formatArray(resp!.found! as ResponseResult[]); + const entities = entity.formatArray( + resp!.found! as ResponseResult[], + options.integerTypeCastOptions + ); const nextKeys = (resp!.deferred || []) .map(entity.keyFromKeyProto) .map(entity.keyToKeyProto); @@ -431,6 +434,11 @@ 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 {object} [options.integerTypeCastOptions] Config for custom `integerValue` cast. + * @param {function} [integerTypeCastOptions.integerTypeCastFunction] A custom user + * provided function to convert `integerValue`. + * @param {sting|string[]} [integerTypeCastOptions.names] `Entity` property + * names to be converted using `integerTypeCastFunction`. * @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 @@ -685,6 +693,11 @@ class DatastoreRequest { * @param {object} [options] Optional configuration. * @param {object} [options.gaxOptions] Request configuration options, outlined * here: https://googleapis.github.io/gax-nodejs/global.html#CallOptions. + * @param {object} [options.integerTypeCastOptions] Config for custom `integerValue` cast. + * @param {function} [integerTypeCastOptions.integerTypeCastFunction] A custom user + * provided function to convert `integerValue`. + * @param {sting|string[]} [integerTypeCastOptions.names] `Entity` property + * names to be converted using `integerTypeCastFunction`. * * @example * datastore.runQueryStream(query) @@ -763,7 +776,10 @@ class DatastoreRequest { let entities: Entity[] = []; if (resp.batch.entityResults) { - entities = entity.formatArray(resp.batch.entityResults); + entities = entity.formatArray( + resp.batch.entityResults, + options.integerTypeCastOptions + ); } // Emit each result right away, then get the rest if necessary. @@ -1400,10 +1416,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 d89b0845a..077b02a7a 100644 --- a/test/entity.ts +++ b/test/entity.ts @@ -16,8 +16,9 @@ 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'; describe('entity', () => { let entity: Entity; @@ -279,6 +280,120 @@ describe('entity', () => { assert.strictEqual(entity.decodeValueProto(valueProto), expectedValue); }); + it('should throw if integer value outside of bounds', () => { + const largeIntegerValue = Number.MAX_SAFE_INTEGER + 1; + const smallIntegerValue = Number.MIN_SAFE_INTEGER - 1; + + const valueProto = { + valueType: 'integerValue', + integerValue: largeIntegerValue, + }; + + const valueProto2 = { + valueType: 'integerValue', + integerValue: smallIntegerValue, + }; + + assert.throws(() => { + entity.decodeValueProto(valueProto); + }, new RegExp(`Integer value ${largeIntegerValue} is out of bounds.`)); + + assert.throws(() => { + entity.decodeValueProto(valueProto2); + }, new RegExp(`Integer value ${smallIntegerValue} is out of bounds.`)); + }); + + it('should custom-cast integerValue when integerTypeCastFunction is provided', () => { + const stub = sinon.stub(); + const expectedValue = 8; + + const valueProto = { + valueType: 'integerValue', + integerValue: expectedValue, + }; + + entity.decodeValueProto(valueProto, {integerTypeCastFunction: stub}); + assert.ok(stub.calledOnce); + }); + + it('should custom-cast integerValue if valueProto.name is specified by user', () => { + const stub = sinon.stub(); + const expectedValue = 8; + + const valueProto = { + valueType: 'integerValue', + integerValue: expectedValue, + name: 'thisValue', + }; + + entity.decodeValueProto(valueProto, { + integerTypeCastFunction: stub, + names: 'thisValue', + }); + assert.ok(stub.calledOnce); + }); + + it('should not custom-cast integerValue if valueProto.name is not specified by user', () => { + const stub = sinon.stub(); + const expectedValue = 8; + + const valueProto = { + valueType: 'integerValue', + integerValue: expectedValue, + name: 'thisValue', + }; + + assert.ok(stub.notCalled); + assert.strictEqual( + entity.decodeValueProto(valueProto, { + integerTypeCastFunction: stub, + names: 'thatValue', + }), + expectedValue + ); + }); + + it('should throw if integerTypeCastFunction is not provided', () => { + const valueProto = { + valueType: 'integerValue', + }; + + assert.throws( + () => entity.decodeValueProto(valueProto, {}), + /integerTypeCastFunction is not a function or was not provided\./ + ); + }); + + it('should throw if integerTypeCastFunction is not a function', () => { + const valueProto = { + valueType: 'integerValue', + }; + + assert.throws( + () => + entity.decodeValueProto(valueProto, {integerTypeCastFunction: {}}), + /integerTypeCastFunction is not a function or was not provided\./ + ); + }); + + it('should propagate error from typeCastfunction', () => { + const valueProto = { + valueType: 'integerValue', + integerValue: 111111, + }; + const errorMessage = 'some error'; + const stub = sinon.stub().throws(errorMessage); + assert.throws( + () => + entity.decodeValueProto(valueProto, {integerTypeCastFunction: stub}), + (err: Error) => { + return new RegExp( + `integerTypeCastFunction threw an error:\n${errorMessage}` + ).test(err.message); + } + ); + }); + it('should decode entities', () => { const expectedValue = {}; @@ -588,6 +703,66 @@ describe('entity', () => { expectedEntity ); }); + + it('should set valueProto.name', () => { + const entityProperty = 'place'; + const expectedEntity = { + [entityProperty]: 'Earth', + }; + + const entityProto = { + properties: { + [entityProperty]: { + valueType: 'stringValue', + stringValue: expectedEntity.place, + }, + }, + }; + entity.decodeValueProto = (valueProto: ValueProto) => { + assert.strictEqual(valueProto.name, entityProperty); + return valueProto.stringValue; + }; + assert.deepStrictEqual( + entity.entityFromEntityProto(entityProto), + expectedEntity + ); + }); + + describe('covert integerValues using custon integerTypeCastFunction', () => { + const entityProto = { + properties: { + number1: { + valueType: 'integerValue', + integerValue: 1000, + }, + number2: { + valueType: 'integerValue', + integerValue: 2000, + }, + number3: { + valueType: 'integerValue', + integerValue: 3000, + }, + }, + }; + + it('should call integerTypeCastFunction on all entity properties', () => { + const stub = sinon.stub(); + entity.entityFromEntityProto(entityProto, { + integerTypeCastFunction: stub, + }); + assert.ok(stub.calledThrice); + }); + + it('should call integerTypeCastFunction only for user specified entity properties', () => { + const stub = sinon.stub(); + entity.entityFromEntityProto(entityProto, { + integerTypeCastFunction: stub, + names: ['number1', 'number2'], + }); + assert.ok(stub.calledTwice); + }); + }); }); describe('entityToEntityProto', () => {