diff --git a/handwritten/spanner/src/codec.ts b/handwritten/spanner/src/codec.ts index 30a591b50807..ab752dea3b77 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 { @@ -124,6 +127,38 @@ 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 + // 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 = 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). if (yearOrDateString === null || yearOrDateString === undefined) { @@ -132,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 (/^\d{4}-\d{1,2}-\d{1,2}/.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. @@ -209,7 +255,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 +395,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 +448,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; @@ -746,26 +792,33 @@ 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}`; + const fieldName = name ? name : `_${i}`; try { - json[fieldName] = convertValueToJson(value, options); + json[fieldName] = convertValueToJson(field.value, resolvedOptions); } catch (e) { (e as Error).message = [ `Serializing column "${fieldName}" encountered an error: ${ @@ -775,7 +828,6 @@ function convertFieldsToJson(fields: Field[], options?: JSONOptions): Json { ].join(' '); throw e; } - index++; } return json; @@ -791,6 +843,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(); } @@ -818,57 +874,129 @@ function convertValueToJson(value: Value, options: JSONOptions): Value { } /** - * Re-decode after the generic gRPC decoding step. + * 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 {*} value Value to decode - * @param {object[]} type Value type object. - * @param columnMetadata Optional parameter to deserialize data - * @returns {*} + * @param {google.spanner.v1.Type} type The Spanner column type metadata. + * @param {object} [columnMetadata] Optional column-specific metadata (e.g., custom schemas). + * @param {JSONOptions} [options] Optional configuration for JSON mode decoding. + * @returns {function} A high-performance decoding closure: `(value: Value) => Value`. */ -function decode( - value: Value, +export function getDecoder( type: spannerClient.spanner.v1.Type, columnMetadata?: object, -): Value { - if (isNull(value)) { - return null; - } + options?: JSONOptions, +): (value: Value) => Value { + const jsonMode = options !== undefined; + const wrapNumbers = !jsonMode || options?.wrapNumbers === true; + const wrapStructs = !jsonMode || options?.wrapStructs === true; - let decoded = value; - let fields; + if (!type) { + return val => val; + } switch (type.code) { case spannerClient.spanner.v1.TypeCode.BYTES: case 'BYTES': - decoded = Buffer.from(decoded, 'base64'); - break; + return val => + val === null || val === undefined ? null : Buffer.from(val, 'base64'); + 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, - }); - break; + if (jsonMode) { + return val => { + if (val === null || val === undefined) { + return null; + } + const decoded = Buffer.from(val, 'base64'); + if (columnMetadata) { + return (columnMetadata as any)['toObject']( + (columnMetadata as any)['decode'](decoded), + ); + } + return decoded.toString(); + }; + } else { + return val => { + if (val === null || val === undefined) { + return null; + } + const decoded = Buffer.from(val, 'base64'); + return new ProtoMessage({ + value: decoded, + fullName: type.protoTypeFqn!, + messageFunction: columnMetadata as Function, + }); + }; + } + case spannerClient.spanner.v1.TypeCode.ENUM: case 'ENUM': - decoded = new ProtoEnum({ - value: decoded, - fullName: type.protoTypeFqn, - enumObject: columnMetadata as object, - }); - break; + if (jsonMode) { + return val => { + if (val === null || val === undefined) { + return null; + } + let enumVal: string; + if (DIGITS_REGEX.test(val.toString())) { + enumVal = val.toString(); + } else if ( + columnMetadata && + Object.prototype.hasOwnProperty.call(columnMetadata, 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); + 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; + }; + } else { + return val => + val === null || val === undefined + ? null + : new ProtoEnum({ + value: val, + fullName: type.protoTypeFqn!, + enumObject: columnMetadata as object, + }); + } + case spannerClient.spanner.v1.TypeCode.FLOAT32: case 'FLOAT32': - decoded = new Float32(decoded); - break; + return val => + val === null || val === undefined + ? null + : wrapNumbers + ? new Float32(val) + : Number(val); + case spannerClient.spanner.v1.TypeCode.FLOAT64: case 'FLOAT64': - decoded = new Float(decoded); - break; + return val => + val === null || val === undefined + ? null + : wrapNumbers + ? new Float(val) + : Number(val); + case spannerClient.spanner.v1.TypeCode.INT64: case 'INT64': if ( @@ -876,11 +1004,35 @@ function decode( spannerClient.spanner.v1.TypeAnnotationCode.PG_OID || type.typeAnnotation === 'PG_OID' ) { - decoded = new PGOid(decoded); - break; + 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 { + 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; + }; } - decoded = new Int(decoded); - break; + case spannerClient.spanner.v1.TypeCode.NUMERIC: case 'NUMERIC': if ( @@ -888,19 +1040,23 @@ function decode( spannerClient.spanner.v1.TypeAnnotationCode.PG_NUMERIC || type.typeAnnotation === 'PG_NUMERIC' ) { - decoded = new PGNumeric(decoded); - break; + return val => + val === null || val === undefined ? null : new PGNumeric(val); + } else { + return val => + val === null || val === undefined ? null : new Numeric(val); } - decoded = new Numeric(decoded); - break; + case spannerClient.spanner.v1.TypeCode.TIMESTAMP: case 'TIMESTAMP': - decoded = new PreciseDate(decoded); - break; + return val => + val === null || val === undefined ? null : parsePreciseDate(val); + case spannerClient.spanner.v1.TypeCode.DATE: case 'DATE': - decoded = new SpannerDate(decoded); - break; + return val => + val === null || val === undefined ? null : new SpannerDate(val); + case spannerClient.spanner.v1.TypeCode.JSON: case 'JSON': if ( @@ -908,42 +1064,227 @@ function decode( spannerClient.spanner.v1.TypeAnnotationCode.PG_JSONB || type.typeAnnotation === 'PG_JSONB' ) { - decoded = new PGJsonb(decoded); - break; + return val => + val === null || val === undefined ? null : new PGJsonb(val); + } else { + return val => + val === null || val === undefined ? null : JSON.parse(val); } - decoded = JSON.parse(decoded); - break; + case spannerClient.spanner.v1.TypeCode.INTERVAL: case 'INTERVAL': - decoded = Interval.fromISO8601(decoded); - break; + return val => + val === null || val === undefined ? null : Interval.fromISO8601(val); + case spannerClient.spanner.v1.TypeCode.ARRAY: - case 'ARRAY': - decoded = decoded.map(value => { - return decode( - value, - type.arrayElementType! as spannerClient.spanner.v1.Type, - columnMetadata, - ); - }); - break; + case 'ARRAY': { + const elementDecoder = getDecoder( + type.arrayElementType! as spannerClient.spanner.v1.Type, + columnMetadata, + options, + ); + return val => + val === null || val === undefined ? null : val.map(elementDecoder); + } + 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], - type as spannerClient.spanner.v1.Type, - columnMetadata, - ); - return {name, value}; - }); - decoded = Struct.fromArray(fields as Field[]); - break; + case 'STRUCT': { + 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 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 => { + if (val === null || val === undefined) { + return null; + } + const isArr = Array.isArray(val); + const structObj: Json = {}; + const len = fieldDecoders.length; + for (let i = 0; i < len; i++) { + const {name, decodedName, decoder, index} = fieldDecoders[i]; + structObj[decodedName!] = decoder( + isArr + ? val[index] + : name !== null && + name !== undefined && + Object.prototype.hasOwnProperty.call(val, name) + ? val[name] + : val[index], + ); + } + return structObj; + }; + } + } + default: - break; + return val => val; + } +} + +/** + * 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); +} + +/** + * 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 = 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); + } + + let milliseconds = 0; + let microseconds = 0; + let nanoseconds = 0; + + 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)); + } else if (isoString.length !== 20) { + return new PreciseDate(isoString); + } + + const utcMillis = Date.UTC( + year, + month, + day, + hours, + minutes, + seconds, + 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); + return preciseDate; } - return decoded; + return new PreciseDate(isoString); } /** @@ -1341,6 +1682,7 @@ export const codec = { PGOid, Interval, convertFieldsToJson, + getDecoder, decode, encode, getType, 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/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..1280e6f844b5 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,11 +242,38 @@ export class PartialResultStream extends Transform implements ResultEvents { if (!this._fields && chunk.metadata) { this._fields = chunk.metadata.rowType! .fields as google.spanner.v1.StructType.Field[]; + + this._decoders = this._fields.map(({name, type}) => { + 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); + } + return codec.getDecoder( + type as google.spanner.v1.Type, + columnMetadata, + this._options.json ? this._options.jsonOptions || {} : undefined, + ); + }); } 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) { @@ -307,7 +338,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 +371,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 +399,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 +414,43 @@ 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}`; + 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; + } /** * Converts an array of values into a row. * @@ -379,18 +460,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 => { diff --git a/handwritten/spanner/test/codec.ts b/handwritten/spanner/test/codec.ts index c889c7e79894..527a37fecea7 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', () => { @@ -1404,6 +1452,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()); @@ -1414,6 +1472,72 @@ 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 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); @@ -1479,6 +1603,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']; @@ -1516,6 +1725,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 957c5f69c575..38700a57d245 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,303 @@ 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(); + }); + + 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', () => { it('should ponyfill the destroy method', done => { const fakeError = new Error('err');