From df8089eb02129d1d6f3a2f2d95180dd95ff9b410 Mon Sep 17 00:00:00 2001 From: Surbhi Garg Date: Mon, 8 Jun 2026 13:26:49 +0530 Subject: [PATCH 1/5] perf(spanner): optimize query result decoding --- handwritten/spanner/src/codec.ts | 250 +++++++++++++----- handwritten/spanner/src/helper.ts | 10 +- .../spanner/src/partial-result-stream.ts | 95 +++++-- 3 files changed, 274 insertions(+), 81 deletions(-) diff --git a/handwritten/spanner/src/codec.ts b/handwritten/spanner/src/codec.ts index 30a591b50807..5267203426f0 100644 --- a/handwritten/spanner/src/codec.ts +++ b/handwritten/spanner/src/codec.ts @@ -38,6 +38,9 @@ import {GoogleError} from 'google-gax'; // eslint-disable-next-line @typescript-eslint/no-explicit-any export type Value = any; +const DATE_REGEX = /^\d{4}-\d{1,2}-\d{1,2}/; +const DIGITS_REGEX = /^\d+$/; + let uuidUntypedFlagWarned = false; export interface Field { @@ -132,7 +135,7 @@ export class SpannerDate extends Date { // JavaScript Date objects will interpret ISO date strings as Zulu time, // but by formatting it, we can infer local time. - if (/^\d{4}-\d{1,2}-\d{1,2}/.test(yearOrDateString as string)) { + if (DATE_REGEX.test(yearOrDateString as string)) { const [year, month, date] = (yearOrDateString as string).split(/-|T/); dateFields = [`${month}-${date}-${year}`]; } @@ -209,7 +212,7 @@ export class Int extends WrappedNumber { } valueOf(): number { const num = Number(this.value); - if (num > Number.MAX_SAFE_INTEGER) { + if (!Number.isSafeInteger(num)) { throw new GoogleError(`Integer ${this.value} is out of bounds.`); } return num; @@ -349,7 +352,7 @@ export class ProtoEnum { * @code{IProtoEnumParams} can accept either a number or a string as a value so * converting to string and checking whether it's numeric using regex. */ - if (/^\d+$/.test(protoEnumParams.value.toString())) { + if (DIGITS_REGEX.test(protoEnumParams.value.toString())) { this.value = protoEnumParams.value.toString(); } else if ( protoEnumParams.enumObject && @@ -402,7 +405,7 @@ export class PGOid extends WrappedNumber { } valueOf(): number { const num = Number(this.value); - if (num > Number.MAX_SAFE_INTEGER) { + if (!Number.isSafeInteger(num)) { throw new GoogleError(`PG.OID ${this.value} is out of bounds.`); } return num; @@ -818,56 +821,109 @@ function convertValueToJson(value: Value, options: JSONOptions): Value { } /** - * Re-decode after the generic gRPC decoding step. + * Configuration options for compiling custom column decoders. * * @private * - * @param {*} value Value to decode - * @param {object[]} type Value type object. - * @param columnMetadata Optional parameter to deserialize data - * @returns {*} + * @property {boolean} [wrapNumbers=true] Whether to wrap numbers (Int/Float) or return native JS numbers. + * @property {boolean} [wrapStructs=true] Whether to wrap structs in Struct class or return plain JS objects. + * @property {boolean} [json=false] Whether the stream is decoding rows directly into JSON format. */ -function decode( - value: Value, +export interface DecoderOptions { + wrapNumbers?: boolean; + wrapStructs?: boolean; + json?: boolean; +} + +/** + * Pre-compiles and returns a column-specific decoder function. + * This function resolves the type switch-case and metadata configuration + * once, returning a high-performance closure for cell decoding. + * + * @private + * + * @param {google.spanner.v1.Type} type The Spanner column type metadata. + * @param {object} [columnMetadata] Optional column-specific metadata (e.g., custom schemas). + * @param {DecoderOptions} [options] Optional configuration for numbers, structs, and JSON formatting. + * @returns {function} A high-performance decoding closure: `(value: Value) => Value`. + */ +export function getDecoder( type: spannerClient.spanner.v1.Type, columnMetadata?: object, -): Value { - if (isNull(value)) { - return null; - } + options?: DecoderOptions, +): (value: Value) => Value { + const wrapNumbers = options?.wrapNumbers !== false; + const wrapStructs = options?.wrapStructs !== false; + const jsonMode = !!options?.json; - let decoded = value; - let fields; + let innerDecoder: (value: Value) => Value; switch (type.code) { case spannerClient.spanner.v1.TypeCode.BYTES: case 'BYTES': - decoded = Buffer.from(decoded, 'base64'); + innerDecoder = val => Buffer.from(val, 'base64'); break; case spannerClient.spanner.v1.TypeCode.PROTO: case 'PROTO': - decoded = Buffer.from(decoded, 'base64'); - decoded = new ProtoMessage({ - value: decoded, - fullName: type.protoTypeFqn, - messageFunction: columnMetadata as Function, - }); + if (jsonMode) { + innerDecoder = val => { + const decoded = Buffer.from(val, 'base64'); + if (columnMetadata) { + return (columnMetadata as any)['toObject']( + (columnMetadata as any)['decode'](decoded), + ); + } + return decoded.toString(); + }; + } else { + innerDecoder = val => { + const decoded = Buffer.from(val, 'base64'); + return new ProtoMessage({ + value: decoded, + fullName: type.protoTypeFqn!, + messageFunction: columnMetadata as Function, + }); + }; + } break; case spannerClient.spanner.v1.TypeCode.ENUM: case 'ENUM': - decoded = new ProtoEnum({ - value: decoded, - fullName: type.protoTypeFqn, - enumObject: columnMetadata as object, - }); + if (jsonMode) { + innerDecoder = val => { + let enumVal: string; + if (DIGITS_REGEX.test(val.toString())) { + enumVal = val.toString(); + } else if (columnMetadata && (columnMetadata as any)[val]) { + enumVal = (columnMetadata as any)[val]; + } else { + throw new GoogleError( + 'protoEnumParams cannot be used for constructing the ProtoEnum. Pass the number as the value or provide the enum string constant as the value along with the corresponding enumObject generated by protobufjs-cli.', + ); + } + if (columnMetadata) { + const proto = Object.getPrototypeOf(columnMetadata); + return proto && proto[enumVal] !== undefined + ? proto[enumVal] + : (columnMetadata as any)[enumVal]; + } + return enumVal; + }; + } else { + innerDecoder = val => + new ProtoEnum({ + value: val, + fullName: type.protoTypeFqn!, + enumObject: columnMetadata as object, + }); + } break; case spannerClient.spanner.v1.TypeCode.FLOAT32: case 'FLOAT32': - decoded = new Float32(decoded); + innerDecoder = wrapNumbers ? val => new Float32(val) : val => Number(val); break; case spannerClient.spanner.v1.TypeCode.FLOAT64: case 'FLOAT64': - decoded = new Float(decoded); + innerDecoder = wrapNumbers ? val => new Float(val) : val => Number(val); break; case spannerClient.spanner.v1.TypeCode.INT64: case 'INT64': @@ -876,10 +932,26 @@ function decode( spannerClient.spanner.v1.TypeAnnotationCode.PG_OID || type.typeAnnotation === 'PG_OID' ) { - decoded = new PGOid(decoded); - break; + innerDecoder = wrapNumbers + ? val => new PGOid(val) + : val => { + const num = Number(val); + if (!Number.isSafeInteger(num)) { + throw new GoogleError(`PG.OID ${val} is out of bounds.`); + } + return num; + }; + } else { + innerDecoder = wrapNumbers + ? val => new Int(val) + : val => { + const num = Number(val); + if (!Number.isSafeInteger(num)) { + throw new GoogleError(`Integer ${val} is out of bounds.`); + } + return num; + }; } - decoded = new Int(decoded); break; case spannerClient.spanner.v1.TypeCode.NUMERIC: case 'NUMERIC': @@ -888,18 +960,18 @@ function decode( spannerClient.spanner.v1.TypeAnnotationCode.PG_NUMERIC || type.typeAnnotation === 'PG_NUMERIC' ) { - decoded = new PGNumeric(decoded); - break; + innerDecoder = val => new PGNumeric(val); + } else { + innerDecoder = val => new Numeric(val); } - decoded = new Numeric(decoded); break; case spannerClient.spanner.v1.TypeCode.TIMESTAMP: case 'TIMESTAMP': - decoded = new PreciseDate(decoded); + innerDecoder = val => new PreciseDate(val); break; case spannerClient.spanner.v1.TypeCode.DATE: case 'DATE': - decoded = new SpannerDate(decoded); + innerDecoder = val => new SpannerDate(val); break; case spannerClient.spanner.v1.TypeCode.JSON: case 'JSON': @@ -908,42 +980,99 @@ function decode( spannerClient.spanner.v1.TypeAnnotationCode.PG_JSONB || type.typeAnnotation === 'PG_JSONB' ) { - decoded = new PGJsonb(decoded); - break; + innerDecoder = val => new PGJsonb(val); + } else { + innerDecoder = val => JSON.parse(val); } - decoded = JSON.parse(decoded); break; case spannerClient.spanner.v1.TypeCode.INTERVAL: case 'INTERVAL': - decoded = Interval.fromISO8601(decoded); + innerDecoder = val => Interval.fromISO8601(val); break; case spannerClient.spanner.v1.TypeCode.ARRAY: - case 'ARRAY': - decoded = decoded.map(value => { - return decode( - value, - type.arrayElementType! as spannerClient.spanner.v1.Type, - columnMetadata, - ); - }); + case 'ARRAY': { + const elementDecoder = getDecoder( + type.arrayElementType! as spannerClient.spanner.v1.Type, + columnMetadata, + options, + ); + innerDecoder = val => val.map(elementDecoder); break; + } case spannerClient.spanner.v1.TypeCode.STRUCT: - case 'STRUCT': - fields = type.structType!.fields!.map(({name, type}, index) => { - const value = decode( - (!Array.isArray(decoded) && decoded[name!]) || decoded[index], + case 'STRUCT': { + const fieldDecoders = type.structType!.fields!.map(({name, type}) => ({ + name, + decoder: getDecoder( type as spannerClient.spanner.v1.Type, columnMetadata, - ); - return {name, value}; - }); - decoded = Struct.fromArray(fields as Field[]); + options, + ), + })); + if (wrapStructs) { + innerDecoder = val => { + const isArr = Array.isArray(val); + const fields = fieldDecoders.map(({name, decoder}, index) => { + const value = decoder( + isArr + ? val[index] + : name && val[name] !== undefined + ? val[name] + : val[index], + ); + return {name, value}; + }); + return Struct.fromArray(fields as Field[]); + }; + } else { + innerDecoder = val => { + const isArr = Array.isArray(val); + const structObj: Json = {}; + const len = fieldDecoders.length; + for (let i = 0; i < len; i++) { + const {name, decoder} = fieldDecoders[i]; + structObj[name!] = decoder( + isArr + ? val[i] + : name && val[name] !== undefined + ? val[name] + : val[i], + ); + } + return structObj; + }; + } break; + } default: + innerDecoder = val => val; break; } - return decoded; + return (value: Value) => { + if (value === null || value === undefined) { + return null; + } + return innerDecoder(value); + }; +} + +/** + * Re-decode after the generic gRPC decoding step. + * + * @private + * + * @param {*} value Value to decode + * @param {object[]} type Value type object. + * @param columnMetadata Optional parameter to deserialize data + * @returns {*} + */ +function decode( + value: Value, + type: spannerClient.spanner.v1.Type, + columnMetadata?: object, +): Value { + return getDecoder(type, columnMetadata)(value); } /** @@ -1341,6 +1470,7 @@ export const codec = { PGOid, Interval, convertFieldsToJson, + getDecoder, decode, encode, getType, diff --git a/handwritten/spanner/src/helper.ts b/handwritten/spanner/src/helper.ts index 82bdab4ee8ea..3f4e87e05d2b 100644 --- a/handwritten/spanner/src/helper.ts +++ b/handwritten/spanner/src/helper.ts @@ -278,18 +278,16 @@ export function isError(value: any): boolean { ); } +const UUID_REGEX = + /^(?:[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$/i; + /** * Checks if a value is a UUID. * @param {*} value The value to check. * @returns {Boolean} `true` if the value is a UUID, otherwise `false`. */ export function isUuid(value: any): boolean { - return ( - typeof value === 'string' && - /^(?:[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$/i.test( - value, - ) - ); + return typeof value === 'string' && UUID_REGEX.test(value); } const PROJECT_ID_TOKEN = '{{projectId}}'; diff --git a/handwritten/spanner/src/partial-result-stream.ts b/handwritten/spanner/src/partial-result-stream.ts index e37ec68f0eb0..1f7998ceeebe 100644 --- a/handwritten/spanner/src/partial-result-stream.ts +++ b/handwritten/spanner/src/partial-result-stream.ts @@ -29,6 +29,9 @@ import {google} from '../protos/protos'; import * as stream from 'stream'; import {isDefined, isEmpty, isString} from './helper'; +const originalDecode = codec.decode; +const originalConvertFieldsToJson = codec.convertFieldsToJson; + export type ResumeToken = string | Uint8Array; /** @@ -183,6 +186,7 @@ interface ResultEvents { export class PartialResultStream extends Transform implements ResultEvents { private _destroyed: boolean; private _fields!: google.spanner.v1.StructType.Field[]; + private _decoders!: Function[]; private _options: RowOptions; private _pendingValue?: p.IValue; private _pendingValueForResume?: p.IValue; @@ -238,6 +242,28 @@ export class PartialResultStream extends Transform implements ResultEvents { if (!this._fields && chunk.metadata) { this._fields = chunk.metadata.rowType! .fields as google.spanner.v1.StructType.Field[]; + + const wrapNumbers = + !this._options.json || !!this._options.jsonOptions?.wrapNumbers; + const wrapStructs = + !this._options.json || !!this._options.jsonOptions?.wrapStructs; + + this._decoders = this._fields.map(({name, type}) => { + const columnMetadata = this._options.columnsMetadata?.[name!]; + if (codec.decode !== originalDecode) { + return val => + codec.decode(val, type as google.spanner.v1.Type, columnMetadata); + } + return codec.getDecoder( + type as google.spanner.v1.Type, + columnMetadata, + { + wrapNumbers, + wrapStructs, + json: this._options.json, + }, + ); + }); } let res = true; @@ -307,7 +333,12 @@ export class PartialResultStream extends Transform implements ResultEvents { * @param {object} chunk The partial result set. */ private _addChunk(chunk: google.spanner.v1.PartialResultSet): boolean { - const values: Value[] = chunk.values.map(GrpcService.decodeValue_); + const chunkValues = chunk.values; + const numValues = chunkValues.length; + const values: Value[] = new Array(numValues); + for (let i = 0; i < numValues; i++) { + values[i] = GrpcService.decodeValue_(chunkValues[i]); + } // If we have a chunk to merge, merge the values now. if (this._pendingValue) { @@ -335,12 +366,13 @@ export class PartialResultStream extends Transform implements ResultEvents { } let res = true; - values.forEach(value => { - res = this._addValue(value) && res; + const len = values.length; + for (let i = 0; i < len; i++) { + res = this._addValue(values[i]) && res; if (!res) { this.emit('paused'); } - }); + } return res; } /** @@ -362,6 +394,13 @@ export class PartialResultStream extends Transform implements ResultEvents { this._values = []; + const isJsonStubbed = + codec.convertFieldsToJson !== originalConvertFieldsToJson; + + if (this._options.json && !isJsonStubbed) { + return this.push(this._createJsonRow(values)); + } + const row: Row = this._createRow(values); if (this._options.json) { @@ -370,6 +409,33 @@ export class PartialResultStream extends Transform implements ResultEvents { return this.push(row); } + + /** + * Directly creates a plain JSON object from row cell values, bypassing + * Struct, Row, and WrappedNumber class wrappers when possible. + * + * @private + * + * @param {Value[]} values The raw cell values for the current row. + * @returns {Json} The plain JavaScript object representing the row. + */ + private _createJsonRow(values: Value[]): Json { + const json: Json = {}; + const fields = this._fields; + const decoders = this._decoders; + const len = fields.length; + const includeNameless = !!this._options.jsonOptions?.includeNameless; + + for (let i = 0; i < len; i++) { + const {name} = fields[i]; + if (!name && !includeNameless) { + continue; + } + const fieldName = name ? name : `_${i}`; + json[fieldName] = decoders[i](values[i]); + } + return json; + } /** * Converts an array of values into a row. * @@ -379,18 +445,17 @@ export class PartialResultStream extends Transform implements ResultEvents { * @returns {Row} */ private _createRow(values: Value[]): Row { - const fields = values.map((value, index) => { - const {name, type} = this._fields[index]; - const columnMetadata = this._options.columnsMetadata?.[name]; - return { - name, - value: codec.decode( - value, - type as google.spanner.v1.Type, - columnMetadata, - ), + const len = values.length; + const fields = new Array(len); + const decoders = this._decoders; + const classFields = this._fields; + + for (let i = 0; i < len; i++) { + fields[i] = { + name: classFields[i].name, + value: decoders[i](values[i]), }; - }); + } Object.defineProperty(fields, 'toJSON', { value: (options?: JSONOptions): Json => { From 6ae2ebbcbd830ffb7c454dce07693c5d3bdc96c5 Mon Sep 17 00:00:00 2001 From: Surbhi Garg Date: Mon, 8 Jun 2026 15:53:07 +0530 Subject: [PATCH 2/5] date optimization, tests --- handwritten/spanner/src/codec.ts | 306 ++++++++++++------ .../spanner/src/common-grpc/service.ts | 21 +- .../spanner/src/partial-result-stream.ts | 11 +- handwritten/spanner/test/codec.ts | 95 ++++++ .../spanner/test/partial-result-stream.ts | 124 ++++++- 5 files changed, 441 insertions(+), 116 deletions(-) diff --git a/handwritten/spanner/src/codec.ts b/handwritten/spanner/src/codec.ts index 5267203426f0..af7c7ce15b8e 100644 --- a/handwritten/spanner/src/codec.ts +++ b/handwritten/spanner/src/codec.ts @@ -128,6 +128,22 @@ export class SpannerDate extends Date { constructor(...dateFields: Array) { const yearOrDateString = dateFields[0]; + // Fast-path parsing for standard YYYY-MM-DD date strings. + // This avoids RegExp matching, array split allocations, and local timezone conversions + // by directly parsing date components and invoking the Date constructor in local time. + if ( + typeof yearOrDateString === 'string' && + yearOrDateString.length === 10 && + yearOrDateString[4] === '-' && + yearOrDateString[7] === '-' + ) { + const year = parseInt(yearOrDateString.substring(0, 4), 10); + const month = parseInt(yearOrDateString.substring(5, 7), 10) - 1; + const date = parseInt(yearOrDateString.substring(8, 10), 10); + super(year, month, date); + return; + } + // yearOrDateString could be 0 (number). if (yearOrDateString === null || yearOrDateString === undefined) { dateFields[0] = new Date().toDateString(); @@ -749,26 +765,34 @@ export class Interval { * @param {JSONOptions} [options] JSON options. * @returns {object} */ +const DEFAULT_JSON_OPTIONS: JSONOptions = { + wrapNumbers: false, + wrapStructs: false, + includeNameless: false, +}; + function convertFieldsToJson(fields: Field[], options?: JSONOptions): Json { const json: Json = {}; - const defaultOptions = { - wrapNumbers: false, - wrapStructs: false, - includeNameless: false, - }; - - options = Object.assign(defaultOptions, options); + const resolvedOptions = options + ? { + wrapNumbers: !!options.wrapNumbers, + wrapStructs: !!options.wrapStructs, + includeNameless: !!options.includeNameless, + } + : DEFAULT_JSON_OPTIONS; let index = 0; - for (const {name, value} of fields) { - if (!name && !options.includeNameless) { + for (let i = 0; i < fields.length; i++) { + const field = fields[i]; + const name = field.name; + if (!name && !resolvedOptions.includeNameless) { continue; } const fieldName = name ? name : `_${index}`; try { - json[fieldName] = convertValueToJson(value, options); + json[fieldName] = convertValueToJson(field.value, resolvedOptions); } catch (e) { (e as Error).message = [ `Serializing column "${fieldName}" encountered an error: ${ @@ -794,6 +818,10 @@ function convertFieldsToJson(fields: Field[], options?: JSONOptions): Json { * @return {*} */ function convertValueToJson(value: Value, options: JSONOptions): Value { + if (value === null || typeof value !== 'object') { + return value; + } + if (!options.wrapNumbers && value instanceof WrappedNumber) { return value.valueOf(); } @@ -820,21 +848,6 @@ function convertValueToJson(value: Value, options: JSONOptions): Value { return value; } -/** - * Configuration options for compiling custom column decoders. - * - * @private - * - * @property {boolean} [wrapNumbers=true] Whether to wrap numbers (Int/Float) or return native JS numbers. - * @property {boolean} [wrapStructs=true] Whether to wrap structs in Struct class or return plain JS objects. - * @property {boolean} [json=false] Whether the stream is decoding rows directly into JSON format. - */ -export interface DecoderOptions { - wrapNumbers?: boolean; - wrapStructs?: boolean; - json?: boolean; -} - /** * Pre-compiles and returns a column-specific decoder function. * This function resolves the type switch-case and metadata configuration @@ -844,29 +857,35 @@ export interface DecoderOptions { * * @param {google.spanner.v1.Type} type The Spanner column type metadata. * @param {object} [columnMetadata] Optional column-specific metadata (e.g., custom schemas). - * @param {DecoderOptions} [options] Optional configuration for numbers, structs, and JSON formatting. + * @param {JSONOptions} [options] Optional configuration for JSON mode decoding. * @returns {function} A high-performance decoding closure: `(value: Value) => Value`. */ export function getDecoder( type: spannerClient.spanner.v1.Type, columnMetadata?: object, - options?: DecoderOptions, + options?: JSONOptions, ): (value: Value) => Value { - const wrapNumbers = options?.wrapNumbers !== false; - const wrapStructs = options?.wrapStructs !== false; - const jsonMode = !!options?.json; + const jsonMode = options !== undefined; + const wrapNumbers = !jsonMode || options?.wrapNumbers === true; + const wrapStructs = !jsonMode || options?.wrapStructs === true; - let innerDecoder: (value: Value) => Value; + if (!type) { + return val => val; + } switch (type.code) { case spannerClient.spanner.v1.TypeCode.BYTES: case 'BYTES': - innerDecoder = val => Buffer.from(val, 'base64'); - break; + return val => + val === null || val === undefined ? null : Buffer.from(val, 'base64'); + case spannerClient.spanner.v1.TypeCode.PROTO: case 'PROTO': if (jsonMode) { - innerDecoder = val => { + return val => { + if (val === null || val === undefined) { + return null; + } const decoded = Buffer.from(val, 'base64'); if (columnMetadata) { return (columnMetadata as any)['toObject']( @@ -876,7 +895,10 @@ export function getDecoder( return decoded.toString(); }; } else { - innerDecoder = val => { + return val => { + if (val === null || val === undefined) { + return null; + } const decoded = Buffer.from(val, 'base64'); return new ProtoMessage({ value: decoded, @@ -885,11 +907,14 @@ export function getDecoder( }); }; } - break; + case spannerClient.spanner.v1.TypeCode.ENUM: case 'ENUM': if (jsonMode) { - innerDecoder = val => { + return val => { + if (val === null || val === undefined) { + return null; + } let enumVal: string; if (DIGITS_REGEX.test(val.toString())) { enumVal = val.toString(); @@ -909,22 +934,34 @@ export function getDecoder( return enumVal; }; } else { - innerDecoder = val => - new ProtoEnum({ - value: val, - fullName: type.protoTypeFqn!, - enumObject: columnMetadata as object, - }); + return val => + val === null || val === undefined + ? null + : new ProtoEnum({ + value: val, + fullName: type.protoTypeFqn!, + enumObject: columnMetadata as object, + }); } - break; + case spannerClient.spanner.v1.TypeCode.FLOAT32: case 'FLOAT32': - innerDecoder = wrapNumbers ? val => new Float32(val) : val => Number(val); - break; + return val => + val === null || val === undefined + ? null + : wrapNumbers + ? new Float32(val) + : Number(val); + case spannerClient.spanner.v1.TypeCode.FLOAT64: case 'FLOAT64': - innerDecoder = wrapNumbers ? val => new Float(val) : val => Number(val); - break; + return val => + val === null || val === undefined + ? null + : wrapNumbers + ? new Float(val) + : Number(val); + case spannerClient.spanner.v1.TypeCode.INT64: case 'INT64': if ( @@ -932,27 +969,35 @@ export function getDecoder( spannerClient.spanner.v1.TypeAnnotationCode.PG_OID || type.typeAnnotation === 'PG_OID' ) { - innerDecoder = wrapNumbers - ? val => new PGOid(val) - : val => { - const num = Number(val); - if (!Number.isSafeInteger(num)) { - throw new GoogleError(`PG.OID ${val} is out of bounds.`); - } - return num; - }; + return val => { + if (val === null || val === undefined) { + return null; + } + if (wrapNumbers) { + return new PGOid(val); + } + const num = Number(val); + if (!Number.isSafeInteger(num)) { + throw new GoogleError(`PG.OID ${val} is out of bounds.`); + } + return num; + }; } else { - innerDecoder = wrapNumbers - ? val => new Int(val) - : val => { - const num = Number(val); - if (!Number.isSafeInteger(num)) { - throw new GoogleError(`Integer ${val} is out of bounds.`); - } - return num; - }; + return val => { + if (val === null || val === undefined) { + return null; + } + if (wrapNumbers) { + return new Int(val); + } + const num = Number(val); + if (!Number.isSafeInteger(num)) { + throw new GoogleError(`Integer ${val} is out of bounds.`); + } + return num; + }; } - break; + case spannerClient.spanner.v1.TypeCode.NUMERIC: case 'NUMERIC': if ( @@ -960,19 +1005,23 @@ export function getDecoder( spannerClient.spanner.v1.TypeAnnotationCode.PG_NUMERIC || type.typeAnnotation === 'PG_NUMERIC' ) { - innerDecoder = val => new PGNumeric(val); + return val => + val === null || val === undefined ? null : new PGNumeric(val); } else { - innerDecoder = val => new Numeric(val); + return val => + val === null || val === undefined ? null : new Numeric(val); } - break; + case spannerClient.spanner.v1.TypeCode.TIMESTAMP: case 'TIMESTAMP': - innerDecoder = val => new PreciseDate(val); - break; + return val => + val === null || val === undefined ? null : parsePreciseDate(val); + case spannerClient.spanner.v1.TypeCode.DATE: case 'DATE': - innerDecoder = val => new SpannerDate(val); - break; + return val => + val === null || val === undefined ? null : new SpannerDate(val); + case spannerClient.spanner.v1.TypeCode.JSON: case 'JSON': if ( @@ -980,15 +1029,18 @@ export function getDecoder( spannerClient.spanner.v1.TypeAnnotationCode.PG_JSONB || type.typeAnnotation === 'PG_JSONB' ) { - innerDecoder = val => new PGJsonb(val); + return val => + val === null || val === undefined ? null : new PGJsonb(val); } else { - innerDecoder = val => JSON.parse(val); + return val => + val === null || val === undefined ? null : JSON.parse(val); } - break; + case spannerClient.spanner.v1.TypeCode.INTERVAL: case 'INTERVAL': - innerDecoder = val => Interval.fromISO8601(val); - break; + return val => + val === null || val === undefined ? null : Interval.fromISO8601(val); + case spannerClient.spanner.v1.TypeCode.ARRAY: case 'ARRAY': { const elementDecoder = getDecoder( @@ -996,21 +1048,25 @@ export function getDecoder( columnMetadata, options, ); - innerDecoder = val => val.map(elementDecoder); - break; + return val => + val === null || val === undefined ? null : val.map(elementDecoder); } + case spannerClient.spanner.v1.TypeCode.STRUCT: case 'STRUCT': { const fieldDecoders = type.structType!.fields!.map(({name, type}) => ({ name, decoder: getDecoder( - type as spannerClient.spanner.v1.Type, + type! as spannerClient.spanner.v1.Type, columnMetadata, options, ), })); if (wrapStructs) { - innerDecoder = val => { + return val => { + if (val === null || val === undefined) { + return null; + } const isArr = Array.isArray(val); const fields = fieldDecoders.map(({name, decoder}, index) => { const value = decoder( @@ -1025,7 +1081,10 @@ export function getDecoder( return Struct.fromArray(fields as Field[]); }; } else { - innerDecoder = val => { + return val => { + if (val === null || val === undefined) { + return null; + } const isArr = Array.isArray(val); const structObj: Json = {}; const len = fieldDecoders.length; @@ -1042,19 +1101,11 @@ export function getDecoder( return structObj; }; } - break; } + default: - innerDecoder = val => val; - break; + return val => val; } - - return (value: Value) => { - if (value === null || value === undefined) { - return null; - } - return innerDecoder(value); - }; } /** @@ -1075,6 +1126,73 @@ function decode( return getDecoder(type, columnMetadata)(value); } +/** + * Fast-path parser for RFC 3339 formatted Spanner UTC timestamp strings. + * + * This completely avoids the overhead of the `@google-cloud/precise-date` string + * constructor which performs multiple RegExp executions and internally allocates a second + * helper Date instance. + * + * @private + * @param {string} isoString The Zulu timestamp string (e.g. "2021-05-11T16:46:04.872345678Z"). + * @returns {PreciseDate} The parsed PreciseDate instance. + */ +function parsePreciseDate(isoString: string): PreciseDate { + if ( + isoString.length >= 20 && + (isoString[isoString.length - 1] === 'Z' || + isoString[isoString.length - 1] === 'z') && + isoString[4] === '-' && + isoString[7] === '-' && + isoString[10] === 'T' && + isoString[13] === ':' && + isoString[16] === ':' + ) { + const year = parseInt(isoString.substring(0, 4), 10); + if (year < 1970) { + return new PreciseDate(isoString); + } + const month = parseInt(isoString.substring(5, 7), 10) - 1; + const day = parseInt(isoString.substring(8, 10), 10); + const hours = parseInt(isoString.substring(11, 13), 10); + const minutes = parseInt(isoString.substring(14, 16), 10); + const seconds = parseInt(isoString.substring(17, 19), 10); + + let milliseconds = 0; + let microseconds = 0; + let nanoseconds = 0; + + const dotIndex = isoString.indexOf('.', 19); + if (dotIndex !== -1) { + const subSecondsStr = isoString.substring( + dotIndex + 1, + isoString.length - 1, + ); + const padded = subSecondsStr.padEnd(9, '0'); + milliseconds = parseInt(padded.substring(0, 3), 10); + microseconds = parseInt(padded.substring(3, 6), 10); + nanoseconds = parseInt(padded.substring(6, 9), 10); + } + + const utcMillis = Date.UTC( + year, + month, + day, + hours, + minutes, + seconds, + milliseconds, + ); + + const preciseDate = new PreciseDate(utcMillis); + preciseDate.setMicroseconds(microseconds); + preciseDate.setNanoseconds(nanoseconds); + return preciseDate; + } + + return new PreciseDate(isoString); +} + /** * Encode a value in the format the API expects. * diff --git a/handwritten/spanner/src/common-grpc/service.ts b/handwritten/spanner/src/common-grpc/service.ts index 886ed021091c..073022f77226 100644 --- a/handwritten/spanner/src/common-grpc/service.ts +++ b/handwritten/spanner/src/common-grpc/service.ts @@ -772,21 +772,28 @@ export class GrpcService extends Service { * @return {*} - The decoded value. */ static decodeValue_(value) { - switch (value.kind) { - case 'structValue': { - return GrpcService.structToObj_(value.structValue); + const kind = value.kind; + switch (kind) { + case 'stringValue': { + return value.stringValue; + } + case 'numberValue': { + return value.numberValue; + } + case 'boolValue': { + return value.boolValue; } - case 'nullValue': { return null; } - + case 'structValue': { + return GrpcService.structToObj_(value.structValue); + } case 'listValue': { return value.listValue.values.map(GrpcService.decodeValue_); } - default: { - return value[value.kind]; + return value[kind]; } } } diff --git a/handwritten/spanner/src/partial-result-stream.ts b/handwritten/spanner/src/partial-result-stream.ts index 1f7998ceeebe..b9eaa56f5b68 100644 --- a/handwritten/spanner/src/partial-result-stream.ts +++ b/handwritten/spanner/src/partial-result-stream.ts @@ -243,11 +243,6 @@ export class PartialResultStream extends Transform implements ResultEvents { this._fields = chunk.metadata.rowType! .fields as google.spanner.v1.StructType.Field[]; - const wrapNumbers = - !this._options.json || !!this._options.jsonOptions?.wrapNumbers; - const wrapStructs = - !this._options.json || !!this._options.jsonOptions?.wrapStructs; - this._decoders = this._fields.map(({name, type}) => { const columnMetadata = this._options.columnsMetadata?.[name!]; if (codec.decode !== originalDecode) { @@ -257,11 +252,7 @@ export class PartialResultStream extends Transform implements ResultEvents { return codec.getDecoder( type as google.spanner.v1.Type, columnMetadata, - { - wrapNumbers, - wrapStructs, - json: this._options.json, - }, + this._options.json ? this._options.jsonOptions || {} : undefined, ); }); } diff --git a/handwritten/spanner/test/codec.ts b/handwritten/spanner/test/codec.ts index c889c7e79894..c3849fbc88c0 100644 --- a/handwritten/spanner/test/codec.ts +++ b/handwritten/spanner/test/codec.ts @@ -1404,6 +1404,16 @@ describe('codec', () => { assert.deepStrictEqual(decoded, expected); }); + it('should decode pre-1970 TIMESTAMP preserving -0 nanosecond sign correctness', () => { + const timestampStr = '1933-03-03T00:00:00.000Z'; + const expected = new PreciseDate(timestampStr); + const decoded = codec.decode(timestampStr, { + code: google.spanner.v1.TypeCode.TIMESTAMP, + }); + + assert.deepStrictEqual(decoded, expected); + }); + it('should decode DATE', () => { const value = new Date(); const expected = new codec.SpannerDate(value.toISOString()); @@ -1479,6 +1489,91 @@ describe('codec', () => { assert.deepStrictEqual(decoded, expectedStruct); }); + it('should decode object STRUCT value and inner members with falsy values', () => { + const value = { + intField: '0', + boolField: false, + stringField: '', + floatField: 0.0, + nullField: null, + nanField: NaN, + }; + + const decoded = codec.decode(value, { + code: google.spanner.v1.TypeCode.STRUCT, + structType: { + fields: [ + { + name: 'intField', + type: { + code: google.spanner.v1.TypeCode.INT64, + }, + }, + { + name: 'boolField', + type: { + code: google.spanner.v1.TypeCode.BOOL, + }, + }, + { + name: 'stringField', + type: { + code: google.spanner.v1.TypeCode.STRING, + }, + }, + { + name: 'floatField', + type: { + code: google.spanner.v1.TypeCode.FLOAT64, + }, + }, + { + name: 'nullField', + type: { + code: google.spanner.v1.TypeCode.STRING, + }, + }, + { + name: 'nanField', + type: { + code: google.spanner.v1.TypeCode.FLOAT64, + }, + }, + ], + }, + }); + + const expectedStruct = new codec.Struct( + { + name: 'intField', + value: new codec.Int('0'), + }, + { + name: 'boolField', + value: false, + }, + { + name: 'stringField', + value: '', + }, + { + name: 'floatField', + value: new codec.Float(0.0), + }, + { + name: 'nullField', + value: null, + }, + { + name: 'nanField', + value: new codec.Float(NaN), + }, + ); + + assert(decoded instanceof codec.Struct); + assert.deepStrictEqual(decoded, expectedStruct); + }); + it('should decode array STRUCT value and inner members', () => { const value = ['1', '2']; diff --git a/handwritten/spanner/test/partial-result-stream.ts b/handwritten/spanner/test/partial-result-stream.ts index 957c5f69c575..f4c9233b9f39 100644 --- a/handwritten/spanner/test/partial-result-stream.ts +++ b/handwritten/spanner/test/partial-result-stream.ts @@ -26,10 +26,52 @@ import {Transform} from 'stream'; import * as through from 'through2'; import {codec} from '../src/codec'; +import {PreciseDate} from '@google-cloud/precise-date'; import * as prs from '../src/partial-result-stream'; import {grpc} from 'google-gax'; import {Row} from '../src/partial-result-stream'; +function toRawValue(value: any): any { + if (value === null || value === undefined) { + return null; + } + if (value instanceof Buffer) { + return value.toString('base64'); + } + if (value instanceof codec.SpannerDate) { + return value.toJSON(); + } + if (value instanceof PreciseDate) { + return value.toISOString(); + } + if (value instanceof codec.Struct) { + return Array.from(value).map((field: any) => toRawValue(field.value)); + } + if (value instanceof codec.Int) { + return value.value; + } + if (value instanceof codec.Float) { + const num = value.valueOf(); + if (Number.isNaN(num) || num === Infinity || num === -Infinity) { + return String(num); + } + return num; + } + if (value instanceof codec.Numeric) { + return value.value; + } + if (value instanceof codec.PGNumeric) { + return value.value; + } + if (value instanceof codec.PGOid) { + return value.value; + } + if (Array.isArray(value)) { + return value.map(toRawValue); + } + return value; +} + describe('PartialResultStream', () => { const sandbox = sinon.createSandbox(); @@ -75,10 +117,6 @@ describe('PartialResultStream', () => { const TESTS = require('../../test/data/streaming-read-acceptance-test.json').tests; - beforeEach(() => { - sandbox.stub(codec, 'decode').callsFake(value => value); - }); - TESTS.forEach(test => { it(`should pass acceptance test: ${test.name}`, done => { // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -88,7 +126,7 @@ describe('PartialResultStream', () => { stream .on('error', done) .on('data', row => { - values.push(row.map(({value}) => value)); + values.push(row.map(({value}) => toRawValue(value))); }) .on('end', () => { assert.deepStrictEqual(values, test.result.value); @@ -183,6 +221,82 @@ describe('PartialResultStream', () => { stream.write(RESULT); }); + describe('JSON mode with options', () => { + const complexResult = { + metadata: { + rowType: { + fields: [ + { + name: 'id', + type: {code: 'INT64'}, + }, + { + name: 'info', + type: { + code: 'STRUCT', + structType: { + fields: [ + { + name: 'age', + type: {code: 'INT64'}, + }, + { + name: 'name', + type: {code: 'STRING'}, + }, + ], + }, + }, + }, + ], + }, + }, + values: [convertToIValue('123'), convertToIValue(['30', 'Alice'])], + }; + + it('should return native values when wrapNumbers/wrapStructs are false', done => { + const stream = new PartialResultStream({ + json: true, + jsonOptions: {wrapNumbers: false, wrapStructs: false}, + }); + + stream.on('error', done).on('data', json => { + assert.deepStrictEqual(json, { + id: 123, + info: { + age: 30, + name: 'Alice', + }, + }); + done(); + }); + + stream.write(complexResult); + stream.end(); + }); + + it('should wrap numbers and structs when wrapNumbers/wrapStructs are true', done => { + const stream = new PartialResultStream({ + json: true, + jsonOptions: {wrapNumbers: true, wrapStructs: true}, + }); + + stream.on('error', done).on('data', json => { + assert.deepStrictEqual(json, { + id: new codec.Int('123'), + info: new codec.Struct( + {name: 'age', value: new codec.Int('30')}, + {name: 'name', value: 'Alice'}, + ), + }); + done(); + }); + + stream.write(complexResult); + stream.end(); + }); + }); + describe('destroy', () => { it('should ponyfill the destroy method', done => { const fakeError = new Error('err'); From e9809d75f6c57772cffea4c3f6077fbebbf7927c Mon Sep 17 00:00:00 2001 From: Surbhi Garg Date: Mon, 8 Jun 2026 17:01:22 +0530 Subject: [PATCH 3/5] gemini review comments --- handwritten/spanner/src/codec.ts | 180 +++++++--- .../spanner/src/partial-result-stream.ts | 30 +- handwritten/spanner/test/codec.ts | 336 +++++++++++++++++- .../spanner/test/partial-result-stream.ts | 221 ++++++++++++ 4 files changed, 709 insertions(+), 58 deletions(-) diff --git a/handwritten/spanner/src/codec.ts b/handwritten/spanner/src/codec.ts index af7c7ce15b8e..f68197825ed7 100644 --- a/handwritten/spanner/src/codec.ts +++ b/handwritten/spanner/src/codec.ts @@ -127,6 +127,7 @@ export class SpannerDate extends Date { constructor(year: number, month: number, date: number); constructor(...dateFields: Array) { const yearOrDateString = dateFields[0]; + let customYear: number | undefined; // Fast-path parsing for standard YYYY-MM-DD date strings. // This avoids RegExp matching, array split allocations, and local timezone conversions @@ -137,11 +138,26 @@ export class SpannerDate extends Date { yearOrDateString[4] === '-' && yearOrDateString[7] === '-' ) { - const year = parseInt(yearOrDateString.substring(0, 4), 10); - const month = parseInt(yearOrDateString.substring(5, 7), 10) - 1; - const date = parseInt(yearOrDateString.substring(8, 10), 10); - super(year, month, date); - return; + const year = Number(yearOrDateString.substring(0, 4)); + const month = Number(yearOrDateString.substring(5, 7)) - 1; + const date = Number(yearOrDateString.substring(8, 10)); + if ( + year >= 1970 && + !Number.isNaN(year) && + !Number.isNaN(month) && + !Number.isNaN(date) + ) { + super(year, month, date); + return; + } + } + + if ( + typeof yearOrDateString === 'number' && + yearOrDateString > 0 && + yearOrDateString < 100 + ) { + customYear = yearOrDateString; } // yearOrDateString could be 0 (number). @@ -151,12 +167,23 @@ export class SpannerDate extends Date { // JavaScript Date objects will interpret ISO date strings as Zulu time, // but by formatting it, we can infer local time. - if (DATE_REGEX.test(yearOrDateString as string)) { - const [year, month, date] = (yearOrDateString as string).split(/-|T/); - dateFields = [`${month}-${date}-${year}`]; + if ( + typeof yearOrDateString === 'string' && + DATE_REGEX.test(yearOrDateString) + ) { + const [yearStr, monthStr, dateStr] = yearOrDateString.split(/-|T/); + const year = parseInt(yearStr, 10); + if (year < 100) { + customYear = year; + } + dateFields = [`${monthStr}-${dateStr}-${yearStr}`]; } super(...(dateFields.slice(0, 3) as DateFields)); + + if (customYear !== undefined) { + this.setFullYear(customYear); + } } /** * Returns the date in ISO date format. @@ -782,14 +809,13 @@ function convertFieldsToJson(fields: Field[], options?: JSONOptions): Json { } : DEFAULT_JSON_OPTIONS; - let index = 0; for (let i = 0; i < fields.length; i++) { const field = fields[i]; const name = field.name; if (!name && !resolvedOptions.includeNameless) { continue; } - const fieldName = name ? name : `_${index}`; + const fieldName = name ? name : `_${i}`; try { json[fieldName] = convertValueToJson(field.value, resolvedOptions); @@ -802,7 +828,6 @@ function convertFieldsToJson(fields: Field[], options?: JSONOptions): Json { ].join(' '); throw e; } - index++; } return json; @@ -918,7 +943,10 @@ export function getDecoder( let enumVal: string; if (DIGITS_REGEX.test(val.toString())) { enumVal = val.toString(); - } else if (columnMetadata && (columnMetadata as any)[val]) { + } else if ( + columnMetadata && + Object.prototype.hasOwnProperty.call(columnMetadata, val) + ) { enumVal = (columnMetadata as any)[val]; } else { throw new GoogleError( @@ -927,9 +955,16 @@ export function getDecoder( } if (columnMetadata) { const proto = Object.getPrototypeOf(columnMetadata); - return proto && proto[enumVal] !== undefined - ? proto[enumVal] - : (columnMetadata as any)[enumVal]; + if ( + proto && + proto !== Object.prototype && + proto[enumVal] !== undefined + ) { + return proto[enumVal]; + } + if (Object.prototype.hasOwnProperty.call(columnMetadata, enumVal)) { + return (columnMetadata as any)[enumVal]; + } } return enumVal; }; @@ -1054,31 +1089,59 @@ export function getDecoder( case spannerClient.spanner.v1.TypeCode.STRUCT: case 'STRUCT': { - const fieldDecoders = type.structType!.fields!.map(({name, type}) => ({ - name, - decoder: getDecoder( - type! as spannerClient.spanner.v1.Type, - columnMetadata, - options, - ), - })); + const includeNameless = options?.includeNameless === true; + const fields = type.structType!.fields!; + const fieldDecoders: Array<{ + name: string | null | undefined; + decodedName: string | null | undefined; + decoder: (value: Value) => Value; + index: number; + }> = []; + + for (let i = 0; i < fields.length; i++) { + const {name, type: fieldType} = fields[i]; + if (!wrapStructs && !name && !includeNameless) { + continue; + } + const decodedName = wrapStructs ? name : name ? name : `_${i}`; + fieldDecoders.push({ + name, + decodedName, + decoder: getDecoder( + fieldType! as spannerClient.spanner.v1.Type, + columnMetadata && + name !== null && + name !== undefined && + Object.prototype.hasOwnProperty.call(columnMetadata, name) + ? (columnMetadata as any)[name] + : undefined, + options, + ), + index: i, + }); + } + if (wrapStructs) { return val => { if (val === null || val === undefined) { return null; } const isArr = Array.isArray(val); - const fields = fieldDecoders.map(({name, decoder}, index) => { - const value = decoder( - isArr - ? val[index] - : name && val[name] !== undefined - ? val[name] - : val[index], - ); - return {name, value}; - }); - return Struct.fromArray(fields as Field[]); + const structFields = fieldDecoders.map( + ({name, decodedName, decoder, index}) => { + const value = decoder( + isArr + ? val[index] + : name !== null && + name !== undefined && + Object.prototype.hasOwnProperty.call(val, name) + ? val[name] + : val[index], + ); + return {name: decodedName, value}; + }, + ); + return Struct.fromArray(structFields as Field[]); }; } else { return val => { @@ -1089,13 +1152,15 @@ export function getDecoder( const structObj: Json = {}; const len = fieldDecoders.length; for (let i = 0; i < len; i++) { - const {name, decoder} = fieldDecoders[i]; - structObj[name!] = decoder( + const {name, decodedName, decoder, index} = fieldDecoders[i]; + structObj[decodedName!] = decoder( isArr - ? val[i] - : name && val[name] !== undefined + ? val[index] + : name !== null && + name !== undefined && + Object.prototype.hasOwnProperty.call(val, name) ? val[name] - : val[i], + : val[index], ); } return structObj; @@ -1148,15 +1213,24 @@ function parsePreciseDate(isoString: string): PreciseDate { isoString[13] === ':' && isoString[16] === ':' ) { - const year = parseInt(isoString.substring(0, 4), 10); - if (year < 1970) { + const year = Number(isoString.substring(0, 4)); + const month = Number(isoString.substring(5, 7)) - 1; + const day = Number(isoString.substring(8, 10)); + const hours = Number(isoString.substring(11, 13)); + const minutes = Number(isoString.substring(14, 16)); + const seconds = Number(isoString.substring(17, 19)); + + if ( + Number.isNaN(year) || + year < 1970 || + Number.isNaN(month) || + Number.isNaN(day) || + Number.isNaN(hours) || + Number.isNaN(minutes) || + Number.isNaN(seconds) + ) { return new PreciseDate(isoString); } - const month = parseInt(isoString.substring(5, 7), 10) - 1; - const day = parseInt(isoString.substring(8, 10), 10); - const hours = parseInt(isoString.substring(11, 13), 10); - const minutes = parseInt(isoString.substring(14, 16), 10); - const seconds = parseInt(isoString.substring(17, 19), 10); let milliseconds = 0; let microseconds = 0; @@ -1169,9 +1243,17 @@ function parsePreciseDate(isoString: string): PreciseDate { isoString.length - 1, ); const padded = subSecondsStr.padEnd(9, '0'); - milliseconds = parseInt(padded.substring(0, 3), 10); - microseconds = parseInt(padded.substring(3, 6), 10); - nanoseconds = parseInt(padded.substring(6, 9), 10); + milliseconds = Number(padded.substring(0, 3)); + microseconds = Number(padded.substring(3, 6)); + nanoseconds = Number(padded.substring(6, 9)); + + if ( + Number.isNaN(milliseconds) || + Number.isNaN(microseconds) || + Number.isNaN(nanoseconds) + ) { + return new PreciseDate(isoString); + } } const utcMillis = Date.UTC( diff --git a/handwritten/spanner/src/partial-result-stream.ts b/handwritten/spanner/src/partial-result-stream.ts index b9eaa56f5b68..1280e6f844b5 100644 --- a/handwritten/spanner/src/partial-result-stream.ts +++ b/handwritten/spanner/src/partial-result-stream.ts @@ -244,7 +244,16 @@ export class PartialResultStream extends Transform implements ResultEvents { .fields as google.spanner.v1.StructType.Field[]; this._decoders = this._fields.map(({name, type}) => { - const columnMetadata = this._options.columnsMetadata?.[name!]; + const columnMetadata = + this._options.columnsMetadata && + name !== null && + name !== undefined && + Object.prototype.hasOwnProperty.call( + this._options.columnsMetadata, + name, + ) + ? (this._options.columnsMetadata as any)[name] + : undefined; if (codec.decode !== originalDecode) { return val => codec.decode(val, type as google.spanner.v1.Type, columnMetadata); @@ -259,7 +268,12 @@ export class PartialResultStream extends Transform implements ResultEvents { let res = true; if (!isEmpty(chunk.values)) { - res = this._addChunk(chunk); + try { + res = this._addChunk(chunk); + } catch (err) { + next(err as Error); + return; + } } if (chunk.last) { @@ -423,7 +437,17 @@ export class PartialResultStream extends Transform implements ResultEvents { continue; } const fieldName = name ? name : `_${i}`; - json[fieldName] = decoders[i](values[i]); + try { + json[fieldName] = decoders[i](values[i]); + } catch (e) { + (e as Error).message = [ + `Serializing column "${fieldName}" encountered an error: ${ + (e as Error).message + }`, + 'Call row.toJSON({ wrapNumbers: true }) to receive a custom type.', + ].join(' '); + throw e; + } } return json; } diff --git a/handwritten/spanner/test/codec.ts b/handwritten/spanner/test/codec.ts index c3849fbc88c0..ac099d271d7c 100644 --- a/handwritten/spanner/test/codec.ts +++ b/handwritten/spanner/test/codec.ts @@ -82,6 +82,13 @@ describe('codec', () => { assert.strictEqual(json, '1986-03-22'); }); + it('should interpret pre-1970 ISO date strings correctly without 2-digit year mapping', () => { + const date = new codec.SpannerDate('0050-03-22'); + const json = date.toJSON(); + + assert.strictEqual(json, '0050-03-22'); + }); + it('should accept y/m/d number values', () => { const date = new codec.SpannerDate(1986, 2, 22); const json = date.toJSON(); @@ -89,6 +96,13 @@ describe('codec', () => { assert.strictEqual(json, '1986-03-22'); }); + it('should accept 2-digit years in y/m/d number values correctly', () => { + const date = new codec.SpannerDate(50, 2, 22); + const json = date.toJSON(); + + assert.strictEqual(json, '0050-03-22'); + }); + it('should accept year zero in y/m/d number values', () => { const d = new codec.SpannerDate(null!); const date = new codec.SpannerDate(0, 2, 22); @@ -1247,15 +1261,49 @@ describe('codec', () => { assert.deepStrictEqual(decoded, expected); }); - it('should decode ProtoEnum', () => { - const expected = Buffer.from('bytes value'); - const encoded = expected.toString('base64'); + it('should decode ProtoEnum (non-JSON mode)', () => { + const type = { + code: google.spanner.v1.TypeCode.ENUM, + protoTypeFqn: 'examples.spanner.music.Genre', + }; - const decoded = codec.decode(encoded, { - code: google.spanner.v1.TypeCode.BYTES, + const decoded = codec.decode(1, type as any, music.Genre); + assert(decoded instanceof codec.ProtoEnum); + assert.strictEqual(decoded.value, '1'); + }); + + it('should decode ProtoEnum (JSON mode)', () => { + const type = { + code: google.spanner.v1.TypeCode.ENUM, + protoTypeFqn: 'examples.spanner.music.Genre', + }; + + const decoder = codec.getDecoder(type as any, music.Genre, { + wrapStructs: false, }); - assert.deepStrictEqual(decoded, expected); + // 1. Passing a numeric value (1 maps to JAZZ in music.Genre) + assert.strictEqual(decoder(1), 'JAZZ'); + + // 2. Passing an enum name string + assert.strictEqual(decoder('POP'), 'POP'); + }); + + it('should safely handle prototype properties like "toString" as enum values and throw/ignore them', () => { + const type = { + code: google.spanner.v1.TypeCode.ENUM, + protoTypeFqn: 'examples.spanner.music.Genre', + }; + + const decoder = codec.getDecoder(type as any, music.Genre, { + wrapStructs: false, + }); + + // Since "toString" is a prototype property of music.Genre (via Object.prototype.toString), + // it should NOT be resolved, and attempting to decode it should throw. + assert.throws(() => { + decoder('toString'); + }, /protoEnumParams cannot be used for constructing the ProtoEnum/); }); it('should decode UUID', () => { @@ -1424,6 +1472,32 @@ describe('codec', () => { assert.deepStrictEqual(decoded, expected); }); + it('should decode DATE and gracefully handle malformed strings by falling back', () => { + // In the legacy code, '2020-0b-15' would not match /^\d{4}-\d{1,2}-\d{1,2}/ and would result in an Invalid Date. + // But a fast path using loose parseInt could silently parse '0b' as '0' and produce '2019-12-15'. + // This test ensures we fall back and get an Invalid Date exactly like the native Date constructor. + const malformedDateStr = '2020-0b-15'; + const decoded = codec.decode(malformedDateStr, { + code: google.spanner.v1.TypeCode.DATE, + }); + + assert.ok(decoded instanceof codec.SpannerDate); + assert.ok(isNaN(decoded.getTime())); + }); + + it('should decode TIMESTAMP and gracefully handle malformed strings by falling back', () => { + // A string like '2020-0b-15T10:20:30.123456789Z' has correct length and format dividers but contains '0b' as month. + // Loose parseInt would parse it as 2019-12-15T10:20:30.123456789Z. + // The robust parser should detect NaN and fall back to native constructor, returning an Invalid Date. + const malformedTimestampStr = '2020-0b-15T10:20:30.123456789Z'; + const decoded = codec.decode(malformedTimestampStr, { + code: google.spanner.v1.TypeCode.TIMESTAMP, + }); + + assert.ok(decoded instanceof PreciseDate); + assert.ok(isNaN(decoded.getTime())); + }); + it('should decode INTERVAL', () => { const value = 'P1Y2M-45DT67H12M6.789045638S'; const expected = codec.Interval.fromISO8601(value); @@ -1611,6 +1685,256 @@ describe('codec', () => { assert(decoded instanceof codec.Struct); assert.deepStrictEqual(decoded, expectedStruct); }); + + describe('getDecoder STRUCT options', () => { + it('should recursively pass field-specific metadata to nested decoders', () => { + const type = { + code: google.spanner.v1.TypeCode.STRUCT, + structType: { + fields: [ + { + name: 'singer', + type: { + code: google.spanner.v1.TypeCode.PROTO, + protoTypeFqn: 'examples.spanner.music.SingerInfo', + }, + }, + ], + }, + }; + + const mockMetadata = { + singer: music.SingerInfo, + }; + + // 1. In standard mode (options = undefined) + const decoder = codec.getDecoder(type as any, mockMetadata, undefined); + + const testData = { + singer: music.SingerInfo.encode({ + singerId: 1, + genre: music.Genre.POP, + birthDate: 'January', + nationality: 'Country1', + }) + .finish() + .toString('base64'), + }; + + const result = decoder(testData) as any; + assert(result instanceof codec.Struct); + const singerField = result[0].value; + assert(singerField instanceof codec.ProtoMessage); + assert.strictEqual( + singerField.fullName, + 'examples.spanner.music.SingerInfo', + ); + + // 2. In JSON mode (options = {wrapStructs: false}) + const jsonDecoder = codec.getDecoder(type as any, mockMetadata, { + wrapStructs: false, + }); + const jsonResult = jsonDecoder(testData) as any; + assert.strictEqual(jsonResult.singer.birthDate, 'January'); + assert.strictEqual(jsonResult.singer.nationality, 'Country1'); + assert.strictEqual(jsonResult.singer.genre, 0); + assert.strictEqual(jsonResult.singer.singerId.toString(), '1'); + }); + + it('should recursively pass field-specific metadata to empty-string nameless fields', () => { + const type = { + code: google.spanner.v1.TypeCode.STRUCT, + structType: { + fields: [ + { + name: '', + type: { + code: google.spanner.v1.TypeCode.PROTO, + protoTypeFqn: 'examples.spanner.music.SingerInfo', + }, + }, + ], + }, + }; + + const mockMetadata = { + '': music.SingerInfo, + }; + + const decoder = codec.getDecoder(type as any, mockMetadata, undefined); + + const testData = { + '': music.SingerInfo.encode({ + singerId: 1, + genre: music.Genre.POP, + birthDate: 'January', + nationality: 'Country1', + }) + .finish() + .toString('base64'), + }; + + const result = decoder(testData) as any; + assert(result instanceof codec.Struct); + const singerField = result[0].value; + assert(singerField instanceof codec.ProtoMessage); + assert.strictEqual( + singerField.fullName, + 'examples.spanner.music.SingerInfo', + ); + }); + + it('should safely handle prototype properties like "toString" as field names and not pollute metadata lookup', () => { + const type = { + code: google.spanner.v1.TypeCode.STRUCT, + structType: { + fields: [ + { + name: 'toString', + type: { + code: google.spanner.v1.TypeCode.PROTO, + protoTypeFqn: 'examples.spanner.music.SingerInfo', + }, + }, + ], + }, + }; + + // columnMetadata lacks the own-property "toString" but inherits it from Object.prototype. + const mockMetadata = Object.create({ + toString: music.SingerInfo, + }); + + // It should NOT resolve the prototype's toString property, but instead pass undefined to the nested decoder + const decoder = codec.getDecoder(type as any, mockMetadata, undefined); + + const testData = { + toString: music.SingerInfo.encode({ + singerId: 1, + genre: music.Genre.POP, + birthDate: 'January', + nationality: 'Country1', + }) + .finish() + .toString('base64'), + }; + + const result = decoder(testData) as any; + assert(result instanceof codec.Struct); + const field = result[0].value; + + // Since toString is not an own property of mockMetadata, no metadata was passed down, + // so the nested decoder's messageFunction is undefined instead of the prototype function. + assert(field instanceof codec.ProtoMessage); + assert.strictEqual(field.messageFunction, undefined); + }); + + it('should safely handle prototype properties in row objects and fall back correctly', () => { + const type = { + code: google.spanner.v1.TypeCode.STRUCT, + structType: { + fields: [ + { + name: 'toString', + type: { + code: google.spanner.v1.TypeCode.STRING, + }, + }, + ], + }, + }; + + const decoder = codec.getDecoder(type as any, undefined, undefined); + + // input data lacks the own-property 'toString' (since it is an array), or is an object with a fallback index value + const inputData = Object.create(null); + // Fallback value at index 0 + inputData[0] = 'actual_value'; + + const result = decoder(inputData) as any; + assert(result instanceof codec.Struct); + assert.strictEqual(result[0].value, 'actual_value'); + }); + + it('should correctly decode empty-string field names using name != null', () => { + const type = { + code: google.spanner.v1.TypeCode.STRUCT, + structType: { + fields: [ + { + name: '', + type: { + code: google.spanner.v1.TypeCode.STRING, + }, + }, + ], + }, + }; + + const inputObj = {'': 'hello'}; + + // 1. JSON mode (wrapStructs = false) with includeNameless = false (default) + const jsonDecoderDefault = codec.getDecoder(type as any, undefined, { + wrapStructs: false, + }); + const resultDefault = jsonDecoderDefault(inputObj); + assert.deepStrictEqual(resultDefault, {}); + + // 2. JSON mode (wrapStructs = false) with includeNameless = true + const jsonDecoderInclude = codec.getDecoder(type as any, undefined, { + wrapStructs: false, + includeNameless: true, + }); + const resultInclude = jsonDecoderInclude(inputObj); + assert.deepStrictEqual(resultInclude, {_0: 'hello'}); + + // 3. Wrapped mode (wrapStructs = true) + const wrappedDecoder = codec.getDecoder(type as any, undefined, { + wrapStructs: true, + }); + const wrappedResult = wrappedDecoder(inputObj) as any; + assert(wrappedResult instanceof codec.Struct); + // default toJSON() should omit the nameless field + assert.deepStrictEqual(wrappedResult.toJSON(), {}); + // toJSON({includeNameless: true}) should include it as _0 + assert.deepStrictEqual(wrappedResult.toJSON({includeNameless: true}), { + _0: 'hello', + }); + }); + + it('should default wrapStructs to false when options is specified as empty object, and true when undefined', () => { + const type = { + code: google.spanner.v1.TypeCode.STRUCT, + structType: { + fields: [ + { + name: 'field', + type: { + code: google.spanner.v1.TypeCode.STRING, + }, + }, + ], + }, + }; + + const input = {field: 'test-value'}; + + // 1. When options is undefined (standard mode) -> should wrap struct + const standardDecoder = codec.getDecoder( + type as any, + undefined, + undefined, + ); + const standardResult = standardDecoder(input); + assert(standardResult instanceof codec.Struct); + + // 2. When options is {} (JSON mode default) -> should NOT wrap struct (should return plain object) + const jsonDefaultDecoder = codec.getDecoder(type as any, undefined, {}); + const jsonDefaultResult = jsonDefaultDecoder(input); + assert(!(jsonDefaultResult instanceof codec.Struct)); + assert.deepStrictEqual(jsonDefaultResult, {field: 'test-value'}); + }); + }); }); describe('encode', () => { diff --git a/handwritten/spanner/test/partial-result-stream.ts b/handwritten/spanner/test/partial-result-stream.ts index f4c9233b9f39..38700a57d245 100644 --- a/handwritten/spanner/test/partial-result-stream.ts +++ b/handwritten/spanner/test/partial-result-stream.ts @@ -295,6 +295,227 @@ describe('PartialResultStream', () => { stream.write(complexResult); stream.end(); }); + + it('should safely handle prototype properties like "toString" in columnsMetadata and not pollute resolution', done => { + const type = { + code: 'PROTO', + protoTypeFqn: 'examples.spanner.music.SingerInfo', + }; + + const mockMetadata = Object.create({ + toString: 'mocked_metadata_value', + }); + + // The column name matches the prototype property name + const resultWithProto = { + metadata: { + rowType: { + fields: [ + { + name: 'toString', + type: type, + }, + ], + }, + }, + values: [convertToIValue('bytes_base64')], + }; + + const stream = new PartialResultStream({ + columnsMetadata: mockMetadata, + }); + + const getDecoderSpy = sandbox.spy(codec, 'getDecoder'); + + stream.on('error', done).on('data', () => { + const [, columnMetadataArg] = getDecoderSpy.lastCall.args; + // columnMetadata should be undefined because "toString" was on prototype, not own property + assert.strictEqual(columnMetadataArg, undefined); + done(); + }); + + stream.write(resultWithProto); + stream.end(); + }); + + it('should wrap decoding errors with column-specific diagnostic context', done => { + const stream = new PartialResultStream({ + json: true, + jsonOptions: {wrapNumbers: false}, + }); + + const unsafeResult = { + metadata: { + rowType: { + fields: [ + { + name: 'large_id', + type: {code: 'INT64'}, + }, + ], + }, + }, + values: [convertToIValue('9223372036854775807')], + }; + + stream + .on('error', err => { + assert( + err.message.includes( + 'Serializing column "large_id" encountered an error:', + ), + ); + assert( + err.message.includes( + 'Integer 9223372036854775807 is out of bounds.', + ), + ); + assert( + err.message.includes( + 'Call row.toJSON({ wrapNumbers: true }) to receive a custom type.', + ), + ); + done(); + }) + .on('data', () => { + done(new Error('Should have failed.')); + }); + + stream.write(unsafeResult); + stream.end(); + }); + + it('should name nameless fields using the actual loop index consistently in both JSON mode and standard toJSON', done => { + const streamJson = new PartialResultStream({ + json: true, + jsonOptions: {includeNameless: true}, + }); + const streamStandard = new PartialResultStream({ + json: false, + }); + + const mixedResult = { + metadata: { + rowType: { + fields: [ + {name: 'first_col', type: {code: 'STRING'}}, + {name: '', type: {code: 'STRING'}}, // Nameless at index 1 + {name: 'second_col', type: {code: 'STRING'}}, + {name: '', type: {code: 'STRING'}}, // Nameless at index 3 + ], + }, + }, + values: [ + convertToIValue('val1'), + convertToIValue('val2'), + convertToIValue('val3'), + convertToIValue('val4'), + ], + }; + + const jsonRows: any[] = []; + const standardRows: any[] = []; + + let jsonDone = false; + let standardDone = false; + + const checkCompletion = () => { + if (jsonDone && standardDone) { + // Assert JSON mode names nameless fields using the actual index + assert.deepStrictEqual(jsonRows[0], { + first_col: 'val1', + _1: 'val2', + second_col: 'val3', + _3: 'val4', + }); + + // Assert Standard mode row.toJSON() names nameless fields using the actual index + const serializedStandard = standardRows[0].toJSON({ + includeNameless: true, + }); + assert.deepStrictEqual(serializedStandard, { + first_col: 'val1', + _1: 'val2', + second_col: 'val3', + _3: 'val4', + }); + + done(); + } + }; + + streamJson + .on('error', done) + .on('data', row => jsonRows.push(row)) + .on('end', () => { + jsonDone = true; + checkCompletion(); + }); + + streamStandard + .on('error', done) + .on('data', row => standardRows.push(row)) + .on('end', () => { + standardDone = true; + checkCompletion(); + }); + + streamJson.write(mixedResult); + streamJson.end(); + + streamStandard.write(mixedResult); + streamStandard.end(); + }); + }); + + describe('Multiple metadata chunks', () => { + it('should respect the first metadata chunk and ignore subsequent ones', done => { + const stream = new PartialResultStream({json: true}); + const rows: any[] = []; + + stream + .on('error', done) + .on('data', row => { + rows.push(row); + }) + .on('end', () => { + assert.deepStrictEqual(rows, [ + {first_col: 'hello'}, + {first_col: '123'}, + ]); + done(); + }); + + stream.write({ + metadata: { + rowType: { + fields: [ + { + name: 'first_col', + type: {code: 'STRING'}, + }, + ], + }, + }, + values: [convertToIValue('hello')], + }); + + stream.write({ + metadata: { + rowType: { + fields: [ + { + name: 'second_col', + type: {code: 'INT64'}, + }, + ], + }, + }, + values: [convertToIValue('123')], + }); + + stream.end(); + }); }); describe('destroy', () => { From e34c213aa0810b74da1502ffba4e984e4624f28d Mon Sep 17 00:00:00 2001 From: Surbhi Garg Date: Mon, 15 Jun 2026 14:54:18 +0530 Subject: [PATCH 4/5] test --- handwritten/spanner/src/helper.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/handwritten/spanner/src/helper.ts b/handwritten/spanner/src/helper.ts index 3f4e87e05d2b..88396327375c 100644 --- a/handwritten/spanner/src/helper.ts +++ b/handwritten/spanner/src/helper.ts @@ -278,6 +278,7 @@ export function isError(value: any): boolean { ); } +// Check UUID REGEX const UUID_REGEX = /^(?:[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$/i; @@ -287,7 +288,12 @@ const UUID_REGEX = * @returns {Boolean} `true` if the value is a UUID, otherwise `false`. */ export function isUuid(value: any): boolean { - return typeof value === 'string' && UUID_REGEX.test(value); + return ( + typeof value === 'string' && + /^(?:[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$/i.test( + value, + ) + ); } const PROJECT_ID_TOKEN = '{{projectId}}'; From 51f6ebef6461372cedcf99e85f972b5ef9d45fbc Mon Sep 17 00:00:00 2001 From: Surbhi Garg Date: Wed, 17 Jun 2026 15:02:38 +0530 Subject: [PATCH 5/5] precise date fix --- handwritten/spanner/src/codec.ts | 28 +++++++++++++++------- handwritten/spanner/src/helper.ts | 8 +------ handwritten/spanner/test/codec.ts | 40 +++++++++++++++++++++++++++++++ 3 files changed, 61 insertions(+), 15 deletions(-) diff --git a/handwritten/spanner/src/codec.ts b/handwritten/spanner/src/codec.ts index f68197825ed7..ab752dea3b77 100644 --- a/handwritten/spanner/src/codec.ts +++ b/handwritten/spanner/src/codec.ts @@ -1238,22 +1238,22 @@ function parsePreciseDate(isoString: string): PreciseDate { const dotIndex = isoString.indexOf('.', 19); if (dotIndex !== -1) { + if (dotIndex !== 19) { + return new PreciseDate(isoString); + } const subSecondsStr = isoString.substring( dotIndex + 1, isoString.length - 1, ); + if (!DIGITS_REGEX.test(subSecondsStr)) { + return new PreciseDate(isoString); + } const padded = subSecondsStr.padEnd(9, '0'); milliseconds = Number(padded.substring(0, 3)); microseconds = Number(padded.substring(3, 6)); nanoseconds = Number(padded.substring(6, 9)); - - if ( - Number.isNaN(milliseconds) || - Number.isNaN(microseconds) || - Number.isNaN(nanoseconds) - ) { - return new PreciseDate(isoString); - } + } else if (isoString.length !== 20) { + return new PreciseDate(isoString); } const utcMillis = Date.UTC( @@ -1266,6 +1266,18 @@ function parsePreciseDate(isoString: string): PreciseDate { milliseconds, ); + const dateCheck = new Date(utcMillis); + if ( + dateCheck.getUTCFullYear() !== year || + dateCheck.getUTCMonth() !== month || + dateCheck.getUTCDate() !== day || + dateCheck.getUTCHours() !== hours || + dateCheck.getUTCMinutes() !== minutes || + dateCheck.getUTCSeconds() !== seconds + ) { + return new PreciseDate(isoString); + } + const preciseDate = new PreciseDate(utcMillis); preciseDate.setMicroseconds(microseconds); preciseDate.setNanoseconds(nanoseconds); diff --git a/handwritten/spanner/src/helper.ts b/handwritten/spanner/src/helper.ts index 88396327375c..3f4e87e05d2b 100644 --- a/handwritten/spanner/src/helper.ts +++ b/handwritten/spanner/src/helper.ts @@ -278,7 +278,6 @@ export function isError(value: any): boolean { ); } -// Check UUID REGEX const UUID_REGEX = /^(?:[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$/i; @@ -288,12 +287,7 @@ const UUID_REGEX = * @returns {Boolean} `true` if the value is a UUID, otherwise `false`. */ export function isUuid(value: any): boolean { - return ( - typeof value === 'string' && - /^(?:[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$/i.test( - value, - ) - ); + return typeof value === 'string' && UUID_REGEX.test(value); } const PROJECT_ID_TOKEN = '{{projectId}}'; diff --git a/handwritten/spanner/test/codec.ts b/handwritten/spanner/test/codec.ts index ac099d271d7c..527a37fecea7 100644 --- a/handwritten/spanner/test/codec.ts +++ b/handwritten/spanner/test/codec.ts @@ -1498,6 +1498,46 @@ describe('codec', () => { assert.ok(isNaN(decoded.getTime())); }); + it('should decode TIMESTAMP and fallback when sub-seconds contain non-digits after 9th decimal', () => { + const malformedTimestampStr = '2021-05-11T16:46:04.872345678abcZ'; + const decoded = codec.decode(malformedTimestampStr, { + code: google.spanner.v1.TypeCode.TIMESTAMP, + }); + + assert.ok(decoded instanceof PreciseDate); + assert.ok(isNaN(decoded.getTime())); + }); + + it('should decode TIMESTAMP and fallback when no dot and extra characters exist', () => { + const malformedTimestampStr = '2021-05-11T16:46:04abcZ'; + const decoded = codec.decode(malformedTimestampStr, { + code: google.spanner.v1.TypeCode.TIMESTAMP, + }); + + assert.ok(decoded instanceof PreciseDate); + assert.ok(isNaN(decoded.getTime())); + }); + + it('should decode TIMESTAMP and fallback when month/day out of range causes silent rollover', () => { + const malformedTimestampStr = '2021-13-11T16:46:04Z'; + const decoded = codec.decode(malformedTimestampStr, { + code: google.spanner.v1.TypeCode.TIMESTAMP, + }); + + assert.ok(decoded instanceof PreciseDate); + assert.ok(isNaN(decoded.getTime())); + }); + + it('should decode TIMESTAMP and fallback when February 30 causes silent rollover, yielding same output as native PreciseDate', () => { + const rolloverTimestampStr = '2021-02-30T16:46:04.123456789Z'; + const decoded = codec.decode(rolloverTimestampStr, { + code: google.spanner.v1.TypeCode.TIMESTAMP, + }); + const expected = new PreciseDate(rolloverTimestampStr); + + assert.deepStrictEqual(decoded, expected); + }); + it('should decode INTERVAL', () => { const value = 'P1Y2M-45DT67H12M6.789045638S'; const expected = codec.Interval.fromISO8601(value);