Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat!: throw error with out of bounds integer values, optionally wrap into DsInt or provide a custom 'integerValue' type cast options #516

Merged
merged 41 commits into from
Nov 14, 2019

Conversation

AVaksman
Copy link
Contributor

@AVaksman AVaksman commented Oct 13, 2019

Fixes #147

  • Tests and linter pass
  • Code coverage does not decrease (if any source code was changed)
  • Appropriate docs were updated (if necessary)

integerValue is now decoded into datastore.Int object

entity.Int#valueof() now

  • On default will try to convert to Number
    • will detect if value is out of bounds of Number
  • Allow user to pass a custom typeCastFunction to be used to convert integer values
    • Give user an option to specify property names to be converted by typeCastFunction

…unds integer values instead of truncating, add custom 'integerValue' type cast option
@googlebot googlebot added the cla: yes This human has signed the Contributor License Agreement. label Oct 13, 2019
@AVaksman AVaksman changed the title feat!: integer values decode into entity.Int object, throw error with out of bounds integer values instead of truncating, add custom 'integerValue' type cast option feat!: integer values decode into DsInt, throw error with out of bounds integer values instead of truncating, add custom 'integerValue' type cast option Oct 13, 2019
@codecov
Copy link

codecov bot commented Oct 13, 2019

Codecov Report

Merging #516 into master will increase coverage by 0.19%.
The diff coverage is 100%.

Impacted file tree graph

@@           Coverage Diff            @@
##           master   #516      +/-   ##
========================================
+ Coverage   93.81%    94%   +0.19%     
========================================
  Files          12     12              
  Lines         889    918      +29     
  Branches      175    189      +14     
========================================
+ Hits          834    863      +29     
  Misses         44     44              
  Partials       11     11
Impacted Files Coverage Δ
src/query.ts 95.83% <ø> (ø) ⬆️
src/entity.ts 98.84% <100%> (+0.1%) ⬆️
src/request.ts 99.54% <100%> (ø) ⬆️

Continue to review full report at Codecov.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 53ddc21...87792e6. Read the comment docs.

@AVaksman
Copy link
Contributor Author

@bcoe please take a look.
Looking at both approaches in this PR and #488, I think this PR came a bit over complex than this feature needs to be 😄. Now I am leaning towards the first option and see add more in the future if there is a need ☺️

src/entity.ts Outdated Show resolved Hide resolved
src/entity.ts Outdated Show resolved Hide resolved
src/entity.ts Outdated Show resolved Hide resolved
src/entity.ts Outdated Show resolved Hide resolved
src/entity.ts Outdated Show resolved Hide resolved
src/entity.ts Outdated Show resolved Hide resolved
src/entity.ts Outdated Show resolved Hide resolved
@stephenplusplus
Copy link
Contributor

Should we think about forcing a user to opt-in or opt-out of the new throwing behavior? In Spanner, by default, the user will receive the Int objects. They can call row.toJSON() on the response from a query, which tries to turn everything into native values (e.g., Number(8) instead of new Int(8)). When a number is out of bounds, it will throw at this time.

The user also has the choice to provide an option, row.toJSON({wrapNumbers:true}), which will return the custom Int type, deferring any throwing until the user tries to use the out of bounds number.

In Datastore terms, we could:

  1. Always try to return the normal Number(int) value (as opposed to new DSInt(int)), and error/throw during the .get() only when a number is out of bounds
  2. Allow an option to get (and anywhere else) that allows wrapNumbers: true. We would always return the custom Int class, which would defer throwing until the user tries to use an out of bounds value

I like this approach, as it would make this breaking change more practical for users in both camps-- where out of bounds integers aren't relevant (no errors), as well as where they are (useful errors). It's almost a fix instead of a breaking change at that point, but I won't argue for that :)

Copy link
Contributor

@bcoe bcoe left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is looking good to me, my only ask would be a few more targeted samples that demonstrate the new API; updating the existing conceps.js is great start.

test/entity.ts Outdated Show resolved Hide resolved
@@ -1018,11 +1018,11 @@ class Transaction extends TestHelper {
// Restore `datastore` to the mock API.
datastore = datastoreMock;
assert.strictEqual(
accounts[0].balance,
accounts[0].balance.valueOf(),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we add a couple examples in:

https://github.com/googleapis/nodejs-datastore/blob/master/samples/concepts.js#L272

That demonstrate:

  1. how to save an entry with an integer value (I believe we already have this).
  2. how to fetch an entry with an integer value.
  3. potentially how we'd JSON stringify an entry we fetched?

I know we're already showing the conversion with accounts[0].balance.valueOf(), but I think a more self contained example might be nice.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not sure this can be done without looking odd. This sample exists alongside other language ones that are this simple.

I would be fine making a future work issue for this.

/cc: @BenWhitehead

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since we don't currently have any section in the docs discussing how to work with "large" numbers, and I'm not sure when we will. I think it'd be good to at least have a commented sample in the repo that can be discovered and referred to independent from this development PR. Once we have the sample we can add a work item to get it published into the docs.

Copy link
Contributor

@crwilcox crwilcox Oct 21, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am capturing this in an internal bug b/143094618.

If you feel strongly this is needed, it may belong in a doc. comment with the function being called?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As long as there is something (either in samples, or in the reference docs alongside the code) so that someone can learn about this, my main concern is satisfied. Since this is a bit of an odd compatibility case that not all of the supported languages necessitate I think it'll take some design/work to incorporate into the docs based on how they're structured right now.

samples/concepts.js Outdated Show resolved Hide resolved
@crwilcox crwilcox marked this pull request as ready for review October 18, 2019 23:10
@AVaksman AVaksman added the kokoro:force-run Add this label to force Kokoro to re-run the tests. label Oct 20, 2019
@kokoro-team kokoro-team removed the kokoro:force-run Add this label to force Kokoro to re-run the tests. label Oct 20, 2019
Copy link
Contributor

@stephenplusplus stephenplusplus left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just wanted to hold this to see if #516 (comment) can turn into a discussion :)

system-test/datastore.ts Outdated Show resolved Hide resolved
}
}
// tslint:disable-next-line no-any
valueOf(): any {
Copy link
Member

@jkwlui jkwlui Oct 21, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can make this type-safe for typescript users by making this class generic on the return type of valueOf():

class Int<T = number> extends Number ...

We'll also be able to make integerTypeCastFunction type-safe by making IntegerTypeCastOptions generic:

export interface IntegerTypeCastOptions<T> {
  integerTypeCastFunction: (value: string) => T;
  properties?: string | string[];
}

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nevermind folks.. It's not possible to override the return type of valueOf() to the generic type since Int inherits from Number. Typescript really dislike type-hacking..

Copy link
Member

@jkwlui jkwlui Oct 21, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This also means Typescript users would have to coerce the type returned by valueOf():

// assume integerTypeCastFunction returns a BigInt
const bigInt = myInt.valueOf() as BigInt;

test/entity.ts Outdated Show resolved Hide resolved
src/entity.ts Outdated Show resolved Hide resolved
src/entity.ts Show resolved Hide resolved
);
}
this.typeCastFunction = typeCastOptions.integerTypeCastFunction;
this.typeCastProperties = typeCastOptions.properties
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's fair to just do this.typeCastProperties = arrify(typeCastOptions.properties), even if it's empty 🤷‍♂

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it won't work. arrify will always return an array (empty) even if typeCastOptions.properties is undefined
A few lined down the logic is

  • only custom cast those properties whose names have been specified by user. As such an empty array will signal to "not to custom cast anything as it is not provided by user".
  • an undefined typeCastOptions.properties will result in custom casting every integerType entity.property

src/entity.ts Outdated
}
// tslint:disable-next-line no-any
valueOf(): any {
let customCast = this.typeCastFunction ? true : false;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It might be a bit more readable to ask exactly what we're wondering:

let shouldCustomCast = typeof this.typeCastFunction === 'function';

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. shouldCustomCast is a better name
  2. we already throwing an error above if this.typeCastFunction is not a function.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The first reason I thought to suggest this change was due to the this.typeCastFunction ? true : false, which is somewhat unusual-- that is coercing the type based on if it's not undefined/null. For readability, I would rather see it say exactly what we're wondering: "We should custom cast the integer if the user gave us a function", vs "We should custom cast the integer if the user gave us a non-null argument".

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got it.
My line of thought was, since we already weeded out anything but a function, at this point the value of this.typeCastFunction can only be a function.
But for code readability purposes it makes sense.
Fixed it.

src/entity.ts Outdated
try {
return this.typeCastFunction!(this.value);
} catch (error) {
error.message = `integerTypeCastFunction threw an error - ${error.message}`;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Up for debate, but newlining here might print better:

error.message = `integerTypeCastFunction returned an error:\n\n  ${error.message}`;

src/query.ts Outdated
* @param {boolean} [options.wrapNumbers=false]
* Indicates if the numbers should be wrapped in Int wrapper.
* @param {object} [options.integerTypeCastOptions] Configurations to
* optionally wrap `integerValue` in Datastore Int object and optionally
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the first sentence can be changed to.

Configuration to convert values of integerValue type to a custom value.

src/query.ts Outdated
* @param {object} [options.integerTypeCastOptions] Configurations to
* optionally wrap `integerValue` in Datastore Int object and optionally
* provide an `integerTypeCastFunction` to handle `integerValue` conversion.
* Note: `integerTypeCastingOptions` values will be ingnored
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: integerTypeCastOptions is ignored when options.wrapNumbers is set.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@crwilcox I added a few tests in the commit 7c366cc to test the "ignore integerTypeCastOptions if options.wrapNumbers is not set to true"

src/query.ts Outdated Show resolved Hide resolved
src/request.ts Outdated
@@ -432,6 +436,17 @@ 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} [options.wrapNumbers=false]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same notes as above throughout this documentation block.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

src/request.ts Outdated
@@ -580,6 +594,17 @@ 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} [options.wrapNumbers=false]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same notes about documentation for this and the properties that follow.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

@crwilcox
Copy link
Contributor

crwilcox commented Nov 9, 2019

@stephenplusplus ptal.

@AVaksman
Copy link
Contributor Author

@stephenplusplus
Latest modifications

combine wrapNumbers: bool and iTCO: iTCO parameters into one wrapNumbersOptions: boolean | iTCO (defaults to false) param.

With new changes user can

  1. Default - query.run(q)
    • API will try to convert to Number
    • Will throw on overflow
  2. Pass options.wNO = true (just wrap numbers, no custom cast) - query.run(q, {wrapNumbersOptions: true})
    • API will return a DsInt obj in which
      • #valueOf will act the same way as default behavior, i.e. try to convert to Number, throw on overflow. However now, in case of an exception with #valueOf user can still retrieve a string representation of the numerical value with #value and does not need to make another network call.
  3. Pass options.wNO = iTCO - query.run(q, {wrapNumbersOptions: {iTCF = myFunction}})
    • API will return a DsInt obj in which
      • #valueOf API will use iTCF to convert the numeric values.
      • #value still returns the string representation of the numerical value

PTAL

src/entity.ts Outdated
'value ' +
value.integerValue +
" is out of bounds of 'Number.MAX_SAFE_INTEGER'.\n" +
"Please consider passing 'options.wrapNumbersOptions=true' or\n" +
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like that you went with a dual-purpose option, where it can be a boolean, for simple mode, or an object for an advanced use case. I would rather have wrapNumbers be the name, however, so that wrapNumbers: true is possible, compared to wrapNumbersOptions: true. When the object is used, wrapNumbers = {integerTypeCastFunction: () => {}} still seems fine.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renamed

* @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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think users will be surprised when they learn integerValues are not converted to what they returned from their function until "valueOf()" is called. Imagining a scenario where a user has their own Int implementation, AppBigInt, I think they would expect instanceof entity.data.myNumber === AppBigInt rather than our DatastoreInt-- which they probably assumed they were opting out of.

If you still think having it work the way it does in this PR now would be best, let's beef up the docs to over-explain how their return value will come into play.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using the logic flow you wrote above, this is my proposal:

  1. Default - query.run(q)
    • API will try to convert to Number
    • Will throw on overflow
  2. Pass options.wrapNumbers as boolean (just wrap numbers, no custom cast) - query.run(q, {wrapNumbers: true})
    • API will return a DsInt obj in which
      • #valueOf will act the same way as default behavior, i.e. try to convert to Number, throw on overflow. However now, in case of an exception with #valueOf user can still retrieve a string representation of the numerical value with #value and does not need to make another network call.
  3. Pass options.wrapNumbers as object - query.run(q, {wrapNumbers: {integerTypeCastFunction: = myFunction}})
    • API will return the return value from user-provided wrapNumbers.integerTypeCastFunction

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Implemented

src/query.ts Outdated
@@ -397,6 +397,10 @@ 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.wrapNumbersOptions=false]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The description should explain "If a boolean, this will wrap values... If an object, you can customize the behavior..."

Copy link
Contributor Author

@AVaksman AVaksman Nov 11, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does below sound ok?

   * @param {boolean | IntegerTypeCastOptions} [options.wrapNumbers=false]
   *     Wrap values of integerValue type in {@link Datastore#Int} object.
   *     If a `boolean`, this will wrap values in {@link Datastore#Int}.
   *     If an `object`, this will return a value returned by 
   *     `wrapNumbers.integerTypeCastFunction`.
   *     Please see {@link  IntegerTypeCastOptions} for options descriptions.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pushed the changes 👍

package.json Outdated Show resolved Hide resolved
src/entity.ts Outdated
'value ' +
value.integerValue +
" is out of bounds of 'Number.MAX_SAFE_INTEGER'.\n" +
"Please consider passing 'options.wrapNumbers=true' or\n" +
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you put the “To prevent this error, please...”. That way, it doesn’t float on its own line after the demonstration object.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

src/entity.ts Outdated
try {
return this.typeCastFunction!(this.value);
} catch (error) {
error.message = `integerTypeCastFunction threw an error:\n\n - ${error.message}`;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you do two spaces in front of the hyphen? I think that’s the standard newline+indent formatting we use in other places.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

src/entity.ts Outdated
/**
* 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} object.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

“objects.”

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed

src/entity.ts Outdated
/**
* 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} object.
* If a `boolean`, this will wrap values in {@link Datastore#Int}.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

“objects.” at the end there please!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed!

test/entity.ts Outdated
const wrapNumbers = {integerTypeCastFunction: stub};

entity.decodeValueProto(valueProto, wrapNumbers);
assert.strictEqual(stub.called, true);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let’s also check that the value returned is what the iTCF returned.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added functionality to integerTypeCastFunction
added an assert to verify entity.decodeValueProto returnes the value returned by integerTypeCastFunction

src/entity.ts Outdated
this.typeCastFunction = typeCastOptions.integerTypeCastFunction;
if (typeof typeCastOptions.integerTypeCastFunction !== 'function') {
throw new Error(
`integerTypeCastFunction is not a function or is not provided.`
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

“was not provided.”

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FIxed!

test/entity.ts Outdated
assert.strictEqual(entity.decodeValueProto(valueProto), expectedValue);
});

it('should wrap nubers with an option', () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

“numbers”

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed!

test/entity.ts Outdated
assert.strictEqual(entity.decodeValueProto(valueProto), expectedValue);
});

it('should not wrap nubers by default', () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

“numbers”

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed as well!

test/request.ts Outdated
@@ -456,6 +456,53 @@ describe('Request', () => {
.emit('reading');
});

it('should pass `wrapNumbersOprions` to formatArray', done => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think these tests should be rewritten to assure all the asserts are called. If necessary, break them apart into multiple tests for each value type.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Split all these tests into single test per each value.

@AVaksman
Copy link
Contributor Author

AVaksman commented Nov 13, 2019

@stephenplusplus
I believe I addressed all the most resent comments PTAL
You can review per commit.

@stephenplusplus
Copy link
Contributor

Thank you very much for your patience with my reviews! It looks great to me!

@AVaksman AVaksman changed the title feat!: throw error with out of bounds integer values, optionally wrap into DsInt, optionally provide a custom 'integerValue' type cast options feat!: throw error with out of bounds integer values, optionally wrap into DsInt or provide a custom 'integerValue' type cast options Nov 14, 2019
@crwilcox crwilcox merged commit 6c8cc74 into googleapis:master Nov 14, 2019
This was referenced Nov 14, 2019
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
cla: yes This human has signed the Contributor License Agreement.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Inconsistent processing of large numbers from other languages.
8 participants