diff --git a/handwritten/spanner/src/codec.ts b/handwritten/spanner/src/codec.ts index be713e7e130..54aa6f4564e 100644 --- a/handwritten/spanner/src/codec.ts +++ b/handwritten/spanner/src/codec.ts @@ -17,15 +17,12 @@ import {GrpcService} from './common-grpc/service'; import {PreciseDate} from '@google-cloud/precise-date'; import { isArray, - isBoolean, isDate, isDecimal, - isInfinite, isInteger, isNull, isNumber, isObject, - isString, isUndefined, isUuid, toArray, @@ -82,8 +79,6 @@ function isValidTimestamp( return isValidDate(year, month, day); } -let uuidUntypedFlagWarned = false; - export interface Field { name: string; value: Value; @@ -1398,6 +1393,7 @@ const TypeCode: { bytes: 'BYTES', json: 'JSON', jsonb: 'JSON', + pgJsonb: 'JSON', interval: 'INTERVAL', proto: 'PROTO', enum: 'ENUM', @@ -1422,6 +1418,24 @@ interface FieldType extends Type { name: string; } +// Parameterless type singletons. These are frozen and reused across `getType` +// calls to eliminate heap allocations and GC pressure during parameter encoding. +const TYPE_STRING: Type = Object.freeze({type: 'string'}); +const TYPE_BOOL: Type = Object.freeze({type: 'bool'}); +const TYPE_INT64: Type = Object.freeze({type: 'int64'}); +const TYPE_FLOAT64: Type = Object.freeze({type: 'float64'}); +const TYPE_FLOAT32: Type = Object.freeze({type: 'float32'}); +const TYPE_NUMERIC: Type = Object.freeze({type: 'numeric'}); +const TYPE_PG_NUMERIC: Type = Object.freeze({type: 'pgNumeric'}); +const TYPE_PG_JSONB: Type = Object.freeze({type: 'pgJsonb'}); +const TYPE_PG_OID: Type = Object.freeze({type: 'pgOid'}); +const TYPE_INTERVAL: Type = Object.freeze({type: 'interval'}); +const TYPE_BYTES: Type = Object.freeze({type: 'bytes'}); +const TYPE_DATE: Type = Object.freeze({type: 'date'}); +const TYPE_TIMESTAMP: Type = Object.freeze({type: 'timestamp'}); +const TYPE_JSON: Type = Object.freeze({type: 'json'}); +const TYPE_UNSPECIFIED: Type = Object.freeze({type: 'unspecified'}); + /** * @typedef {ParamType} StructField * @property {string} name The name of the field. @@ -1434,6 +1448,9 @@ interface FieldType extends Type { * - float64 * - int64 * - numeric + * - pgNumeric + * - pgJsonb + * - pgOid * - bool * - string * - bytes @@ -1445,18 +1462,45 @@ interface FieldType extends Type { * - date * - struct * - array + * - unspecified * @property {StructField[]} [fields] **For struct types only**. Type * definitions for the individual fields. * @property {string|ParamType} [child] **For array types only**. The array * element type. */ +let uuidUntypedFlagWarned = false; + +function _resetUuidUntypedFlagWarnedForTest(): void { + uuidUntypedFlagWarned = false; +} + +function isUuidUntypedEnv(): boolean { + if (process.env['SPANNER_ENABLE_UUID_AS_UNTYPED']?.toLowerCase() === 'true') { + if (!uuidUntypedFlagWarned) { + process.emitWarning( + 'SPANNER_ENABLE_UUID_AS_UNTYPED environment variable is deprecated and will be removed in a future release.', + 'DeprecationWarning', + ); + uuidUntypedFlagWarned = true; + } + return true; + } + return false; +} + /** * Get the corresponding Spanner data type for the provided value. * + * NOTE: This function is internal and not exposed in the public API. + * Parameterless types return shared, frozen singletons to eliminate allocation + * and GC overhead. Callers must treat the returned Type descriptor as read-only. + * * @private * * @param {*} value - The value. - * @returns {object} + * @param {boolean} [enableUuidAsUntyped] - Whether UUID strings should be + * typed as unspecified. + * @returns {Type} * * @example * ``` @@ -1464,93 +1508,90 @@ interface FieldType extends Type { * // {type: 'float64'} * ``` */ -function getType(value: Value): Type { - const isSpecialNumber = - isInfinite(value) || (isNumber(value) && isNaN(value)); +function getType(value: Value, enableUuidAsUntyped?: boolean): Type { + if (value === null || value === undefined) { + return TYPE_UNSPECIFIED; + } - if (value instanceof Float32) { - return {type: 'float32'}; + if (typeof value === 'string') { + const isUuidUntyped = enableUuidAsUntyped ?? isUuidUntypedEnv(); + if (isUuidUntyped && isUuid(value)) { + return TYPE_UNSPECIFIED; + } + return TYPE_STRING; } - if (isDecimal(value) || isSpecialNumber || value instanceof Float) { - return {type: 'float64'}; + if (typeof value === 'number') { + return Number.isInteger(value) ? TYPE_INT64 : TYPE_FLOAT64; } - if (isNumber(value) || value instanceof Int) { - return {type: 'int64'}; + if (typeof value === 'boolean') { + return TYPE_BOOL; } - if (value instanceof Numeric) { - return {type: 'numeric'}; + if (Buffer.isBuffer(value)) { + return TYPE_BYTES; } - if (value instanceof PGNumeric) { - return {type: 'pgNumeric'}; + if (value instanceof SpannerDate) { + return TYPE_DATE; } - if (value instanceof PGJsonb) { - return {type: 'pgJsonb'}; + if (value instanceof Date || isDate(value)) { + return TYPE_TIMESTAMP; } - if (value instanceof PGOid) { - return {type: 'pgOid'}; + if (value instanceof Int) { + return TYPE_INT64; } - if (value instanceof Interval) { - return {type: 'interval'}; + if (value instanceof Float) { + return TYPE_FLOAT64; } - if (value instanceof ProtoMessage) { - return {type: 'proto', fullName: value.fullName}; + if (value instanceof Float32) { + return TYPE_FLOAT32; } - if (value instanceof ProtoEnum) { - return {type: 'enum', fullName: value.fullName}; + if (value instanceof Numeric) { + return TYPE_NUMERIC; } - if (isBoolean(value)) { - return {type: 'bool'}; + if (value instanceof PGNumeric) { + return TYPE_PG_NUMERIC; } - if (process.env['SPANNER_ENABLE_UUID_AS_UNTYPED'] === 'true') { - if (!uuidUntypedFlagWarned) { - process.emitWarning( - 'SPANNER_ENABLE_UUID_AS_UNTYPED environment variable is deprecated and will be removed in a future release.', - 'DeprecationWarning', - ); - uuidUntypedFlagWarned = true; - } - if (isUuid(value)) { - return {type: 'unspecified'}; - } + if (value instanceof PGJsonb) { + return TYPE_PG_JSONB; } - if (isString(value)) { - return {type: 'string'}; + if (value instanceof PGOid) { + return TYPE_PG_OID; } - if (Buffer.isBuffer(value)) { - return {type: 'bytes'}; + if (value instanceof Interval) { + return TYPE_INTERVAL; } - if (value instanceof SpannerDate) { - return {type: 'date'}; + if (value instanceof ProtoMessage) { + return {type: 'proto', fullName: value.fullName}; } - if (isDate(value)) { - return {type: 'timestamp'}; + if (value instanceof ProtoEnum) { + return {type: 'enum', fullName: value.fullName}; } if (value instanceof Struct) { + const isUuidUntyped = enableUuidAsUntyped ?? isUuidUntypedEnv(); return { type: 'struct', fields: Array.from(value).map(({name, value}) => { - return Object.assign({name}, getType(value)); + return Object.assign({name}, getType(value, isUuidUntyped)); }), }; } - if (isArray(value)) { + if (Array.isArray(value)) { let child; for (let i = 0; i < value.length; i++) { @@ -1563,15 +1604,27 @@ function getType(value: Value): Type { return { type: 'array', - child: getType(child), + child: getType(child, enableUuidAsUntyped), }; } + if (value instanceof String) { + return TYPE_STRING; + } + + if (value instanceof Number) { + return Number.isInteger(value.valueOf()) ? TYPE_INT64 : TYPE_FLOAT64; + } + + if (value instanceof Boolean) { + return TYPE_BOOL; + } + if (isObject(value)) { - return {type: 'json'}; + return TYPE_JSON; } - return {type: 'unspecified'}; + return TYPE_UNSPECIFIED; } /** @@ -1666,7 +1719,7 @@ function createTypeObject( if (friendlyType.type === 'pgNumeric') { type.typeAnnotation = spannerClient.spanner.v1.TypeAnnotationCode.PG_NUMERIC; - } else if (friendlyType.type === 'jsonb') { + } else if (friendlyType.type === 'jsonb' || friendlyType.type === 'pgJsonb') { type.typeAnnotation = spannerClient.spanner.v1.TypeAnnotationCode.PG_JSONB; } else if (friendlyType.type === 'pgOid') { type.typeAnnotation = spannerClient.spanner.v1.TypeAnnotationCode.PG_OID; @@ -1696,4 +1749,6 @@ export const codec = { encode, getType, Struct, + _resetUuidUntypedFlagWarnedForTest, + isUuidUntypedEnv, }; diff --git a/handwritten/spanner/src/transaction.ts b/handwritten/spanner/src/transaction.ts index f84b73362eb..106feb29c2f 100644 --- a/handwritten/spanner/src/transaction.ts +++ b/handwritten/spanner/src/transaction.ts @@ -2208,6 +2208,7 @@ export class Snapshot extends EventEmitter { * @returns {object} */ static encodeParams(request: ExecuteSqlRequest) { + const isUuidUntyped = codec.isUuidUntypedEnv(); const typeMap = request.types || {}; const params: p.IStruct = {fields: request.params?.fields || {}}; @@ -2221,7 +2222,7 @@ export class Snapshot extends EventEmitter { const value = request.params![param]; if (!typeMap[param]) { - typeMap[param] = codec.getType(value); + typeMap[param] = codec.getType(value, isUuidUntyped); } fields[param] = codec.encode(value); }); @@ -2232,7 +2233,7 @@ export class Snapshot extends EventEmitter { if (!isEmpty(typeMap)) { Object.keys(typeMap).forEach(param => { const type = typeMap[param]; - if (process.env['SPANNER_ENABLE_UUID_AS_UNTYPED'] === 'true') { + if (isUuidUntyped) { const typeObject = codec.createTypeObject(type); if ( (type.child && diff --git a/handwritten/spanner/test/codec.ts b/handwritten/spanner/test/codec.ts index 6d3dddae938..43b3e5a2ac5 100644 --- a/handwritten/spanner/test/codec.ts +++ b/handwritten/spanner/test/codec.ts @@ -2421,7 +2421,7 @@ describe('codec', () => { assert.deepStrictEqual(codec.getType(new Date()), {type: 'timestamp'}); }); - it.skip('should determine if the value is a interval', () => { + it('should determine if the value is an interval', () => { assert.deepStrictEqual( codec.getType(new codec.Interval(1, 2, BigInt(3))), { @@ -2449,8 +2449,31 @@ describe('codec', () => { }); }); + it('should skip leading nulls in arrays to determine child type', () => { + assert.deepStrictEqual(codec.getType([null, null, 'hello']), { + type: 'array', + child: {type: 'string'}, + }); + }); + + it('should type empty arrays as array with unspecified child', () => { + assert.deepStrictEqual(codec.getType([]), { + type: 'array', + child: {type: 'unspecified'}, + }); + }); + it('should return unspecified for unknown values', () => { assert.deepStrictEqual(codec.getType(null), {type: 'unspecified'}); + assert.deepStrictEqual(codec.getType(undefined), {type: 'unspecified'}); + assert.deepStrictEqual(codec.getType(BigInt(42)), {type: 'unspecified'}); + assert.deepStrictEqual(codec.getType(Symbol('sym')), { + type: 'unspecified', + }); + assert.deepStrictEqual( + codec.getType(() => {}), + {type: 'unspecified'}, + ); assert.deepStrictEqual(codec.getType([null]), { type: 'array', @@ -2466,11 +2489,229 @@ describe('codec', () => { }); }); + it('should determine if the value is a PGJsonb', () => { + assert.deepStrictEqual(codec.getType(new codec.PGJsonb({key: 'value'})), { + type: 'pgJsonb', + }); + }); + it('should determine if the value is a PGOid', () => { assert.deepStrictEqual(codec.getType(new codec.PGOid(5678)), { type: 'pgOid', }); }); + + it('should determine if the value is a ProtoMessage', () => { + const protoMessage = new codec.ProtoMessage({ + value: Buffer.from('abc'), + fullName: 'my.proto.Message', + }); + assert.deepStrictEqual(codec.getType(protoMessage), { + type: 'proto', + fullName: 'my.proto.Message', + }); + }); + + it('should determine if the value is a ProtoEnum', () => { + const protoEnum = new codec.ProtoEnum({ + value: 1, + fullName: 'my.proto.Enum', + }); + assert.deepStrictEqual(codec.getType(protoEnum), { + type: 'enum', + fullName: 'my.proto.Enum', + }); + }); + + it('should determine if zero and negative numbers are typed correctly', () => { + assert.deepStrictEqual(codec.getType(0), {type: 'int64'}); + assert.deepStrictEqual(codec.getType(-0), {type: 'int64'}); + assert.deepStrictEqual(codec.getType(-42), {type: 'int64'}); + assert.deepStrictEqual(codec.getType(-3.14), {type: 'float64'}); + }); + + it('should determine if false is a boolean', () => { + assert.deepStrictEqual(codec.getType(false), {type: 'bool'}); + }); + + it('should determine if the uuid value is unspecified when enableUuidAsUntyped is true without emitting warning', () => { + codec._resetUuidUntypedFlagWarnedForTest(); + const emitWarningStub = sandbox.stub(process, 'emitWarning'); + try { + assert.deepStrictEqual(codec.getType(crypto.randomUUID(), true), { + type: 'unspecified', + }); + assert.strictEqual(emitWarningStub.called, false); + } finally { + emitWarningStub.restore(); + } + }); + + it('should determine if the uuid value is unspecified when SPANNER_ENABLE_UUID_AS_UNTYPED is case-insensitively TRUE', () => { + codec._resetUuidUntypedFlagWarnedForTest(); + const emitWarningStub = sandbox.stub(process, 'emitWarning'); + try { + process.env['SPANNER_ENABLE_UUID_AS_UNTYPED'] = 'TRUE'; + assert.deepStrictEqual(codec.getType(crypto.randomUUID()), { + type: 'unspecified', + }); + assert.strictEqual(emitWarningStub.calledOnce, true); + assert.strictEqual( + emitWarningStub.firstCall.args[0], + 'SPANNER_ENABLE_UUID_AS_UNTYPED environment variable is deprecated and will be removed in a future release.', + ); + } finally { + delete process.env['SPANNER_ENABLE_UUID_AS_UNTYPED']; + emitWarningStub.restore(); + } + }); + + it('should not evaluate SPANNER_ENABLE_UUID_AS_UNTYPED for non-string types', () => { + codec._resetUuidUntypedFlagWarnedForTest(); + const emitWarningStub = sandbox.stub(process, 'emitWarning'); + try { + process.env['SPANNER_ENABLE_UUID_AS_UNTYPED'] = 'true'; + assert.deepStrictEqual(codec.getType(123), {type: 'int64'}); + assert.deepStrictEqual(codec.getType(true), {type: 'bool'}); + assert.deepStrictEqual(codec.getType(Buffer.from('a')), { + type: 'bytes', + }); + assert.deepStrictEqual(codec.getType(new Date()), {type: 'timestamp'}); + assert.strictEqual(emitWarningStub.called, false); + } finally { + delete process.env['SPANNER_ENABLE_UUID_AS_UNTYPED']; + emitWarningStub.restore(); + } + }); + + it('should determine if the uuid value is string when enableUuidAsUntyped is false', () => { + try { + process.env['SPANNER_ENABLE_UUID_AS_UNTYPED'] = 'true'; + assert.deepStrictEqual(codec.getType(crypto.randomUUID(), false), { + type: 'string', + }); + } finally { + delete process.env['SPANNER_ENABLE_UUID_AS_UNTYPED']; + } + }); + + it('should determine if non-uuid string is string when enableUuidAsUntyped is true', () => { + assert.deepStrictEqual(codec.getType('not-a-uuid', true), { + type: 'string', + }); + }); + + it('should recursively propagate enableUuidAsUntyped to array elements', () => { + const uuid = crypto.randomUUID(); + assert.deepStrictEqual(codec.getType([uuid], true), { + type: 'array', + child: {type: 'unspecified'}, + }); + assert.deepStrictEqual(codec.getType([uuid], false), { + type: 'array', + child: {type: 'string'}, + }); + }); + + it('should recursively propagate enableUuidAsUntyped to struct fields', () => { + const uuid = crypto.randomUUID(); + const struct = codec.Struct.fromJSON({id: uuid}); + assert.deepStrictEqual(codec.getType(struct, true), { + type: 'struct', + fields: [{name: 'id', type: 'unspecified'}], + }); + assert.deepStrictEqual(codec.getType(struct, false), { + type: 'struct', + fields: [{name: 'id', type: 'string'}], + }); + }); + + it('should determine type of boxed primitives', () => { + assert.deepStrictEqual(codec.getType(new String('hello')), { + type: 'string', + }); + assert.deepStrictEqual(codec.getType(new Number(42)), {type: 'int64'}); + assert.deepStrictEqual(codec.getType(new Number(3.14)), { + type: 'float64', + }); + assert.deepStrictEqual(codec.getType(new Boolean(false)), {type: 'bool'}); + assert.deepStrictEqual(codec.getType(new Boolean(true)), {type: 'bool'}); + }); + + it('should return frozen singletons for parameterless types', () => { + assert.strictEqual(codec.getType('abc'), codec.getType('def')); + assert.strictEqual(codec.getType(123), codec.getType(456)); + assert.strictEqual(codec.getType(123), codec.getType(new codec.Int(1))); + assert.strictEqual(codec.getType(1.23), codec.getType(4.56)); + assert.strictEqual(codec.getType(1.23), codec.getType(NaN)); + assert.strictEqual( + codec.getType(1.23), + codec.getType(new codec.Float(1.1)), + ); + assert.strictEqual( + codec.getType(new codec.Float32(1.1)), + codec.getType(new codec.Float32(2.2)), + ); + assert.strictEqual(codec.getType(true), codec.getType(false)); + assert.strictEqual(codec.getType(null), codec.getType(undefined)); + assert.strictEqual( + codec.getType(Buffer.from('a')), + codec.getType(Buffer.from('b')), + ); + assert.strictEqual( + codec.getType(new codec.SpannerDate()), + codec.getType(new codec.SpannerDate('2025-01-01')), + ); + assert.strictEqual( + codec.getType(new Date()), + codec.getType(new PreciseDate()), + ); + assert.strictEqual( + codec.getType(new codec.Numeric('1')), + codec.getType(new codec.Numeric('2')), + ); + assert.strictEqual( + codec.getType(new codec.PGNumeric('1')), + codec.getType(new codec.PGNumeric('2')), + ); + assert.strictEqual( + codec.getType(new codec.PGJsonb({})), + codec.getType(new codec.PGJsonb({a: 1})), + ); + assert.strictEqual( + codec.getType(new codec.PGOid(1)), + codec.getType(new codec.PGOid(2)), + ); + assert.strictEqual( + codec.getType(new codec.Interval(1, 2, BigInt(3))), + codec.getType(new codec.Interval(4, 5, BigInt(6))), + ); + assert.strictEqual(codec.getType({a: 1}), codec.getType({b: 2})); + + assert(Object.isFrozen(codec.getType('abc'))); + assert(Object.isFrozen(codec.getType(123))); + assert(Object.isFrozen(codec.getType(1.23))); + assert(Object.isFrozen(codec.getType(new codec.Float32(1.1)))); + assert(Object.isFrozen(codec.getType(true))); + assert(Object.isFrozen(codec.getType(null))); + assert(Object.isFrozen(codec.getType(Buffer.from('a')))); + assert(Object.isFrozen(codec.getType(new codec.SpannerDate()))); + assert(Object.isFrozen(codec.getType(new Date()))); + assert(Object.isFrozen(codec.getType(new codec.Numeric('1')))); + assert(Object.isFrozen(codec.getType(new codec.PGNumeric('1')))); + assert(Object.isFrozen(codec.getType(new codec.PGJsonb({})))); + assert(Object.isFrozen(codec.getType(new codec.PGOid(1)))); + assert( + Object.isFrozen(codec.getType(new codec.Interval(1, 2, BigInt(3)))), + ); + assert(Object.isFrozen(codec.getType({a: 1}))); + }); + + it('should use frozen singletons for array child types', () => { + const arrayType = codec.getType(['abc']); + assert.strictEqual(arrayType.child, codec.getType('abc')); + assert(Object.isFrozen(arrayType.child)); + }); }); describe('convertToListValue', () => { @@ -2723,5 +2964,23 @@ describe('codec', () => { typeAnnotation: google.spanner.v1.TypeAnnotationCode.PG_OID, }); }); + + it('should set code and typeAnnotation for pgJsonb string', () => { + const type = codec.createTypeObject('pgJsonb'); + + assert.deepStrictEqual(type, { + code: google.spanner.v1.TypeCode[google.spanner.v1.TypeCode.JSON], + typeAnnotation: google.spanner.v1.TypeAnnotationCode.PG_JSONB, + }); + }); + + it('should set code and typeAnnotation for pgJsonb friendlyType object', () => { + const type = codec.createTypeObject({type: 'pgJsonb'}); + + assert.deepStrictEqual(type, { + code: google.spanner.v1.TypeCode[google.spanner.v1.TypeCode.JSON], + typeAnnotation: google.spanner.v1.TypeAnnotationCode.PG_JSONB, + }); + }); }); });