diff --git a/handwritten/spanner-pg/.eslintignore b/handwritten/spanner-pg/.eslintignore new file mode 100644 index 000000000000..56ca9054b7ab --- /dev/null +++ b/handwritten/spanner-pg/.eslintignore @@ -0,0 +1,5 @@ +**/node_modules +**/coverage +build/ +docs/ +test/pg-suite/ diff --git a/handwritten/spanner-pg/.eslintrc.json b/handwritten/spanner-pg/.eslintrc.json new file mode 100644 index 000000000000..aa462ccc3ae7 --- /dev/null +++ b/handwritten/spanner-pg/.eslintrc.json @@ -0,0 +1,4 @@ +{ + "root": true, + "extends": "./node_modules/gts" +} diff --git a/handwritten/spanner-pg/.gitignore b/handwritten/spanner-pg/.gitignore new file mode 100644 index 000000000000..9c8d9932fe38 --- /dev/null +++ b/handwritten/spanner-pg/.gitignore @@ -0,0 +1,12 @@ +**/*.log +**/node_modules +/.coverage +/coverage +/.nyc_output +/docs/ +/out/ +/build/ +*.lock +.DS_Store +package-lock.json +pg-suite \ No newline at end of file diff --git a/handwritten/spanner-pg/.mocharc.cjs b/handwritten/spanner-pg/.mocharc.cjs new file mode 100644 index 000000000000..6d69c480e8b8 --- /dev/null +++ b/handwritten/spanner-pg/.mocharc.cjs @@ -0,0 +1,30 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +const config = { + "enable-source-maps": true, + "throw-deprecation": true, + "timeout": 10000, + "recursive": true +} +if (process.env.MOCHA_THROW_DEPRECATION === 'false') { + delete config['throw-deprecation']; +} +if (process.env.MOCHA_REPORTER) { + config.reporter = process.env.MOCHA_REPORTER; +} +if (process.env.MOCHA_REPORTER_OUTPUT) { + config['reporter-option'] = `output=${process.env.MOCHA_REPORTER_OUTPUT}`; +} +module.exports = config diff --git a/handwritten/spanner-pg/.prettierignore b/handwritten/spanner-pg/.prettierignore new file mode 100644 index 000000000000..56ca9054b7ab --- /dev/null +++ b/handwritten/spanner-pg/.prettierignore @@ -0,0 +1,5 @@ +**/node_modules +**/coverage +build/ +docs/ +test/pg-suite/ diff --git a/handwritten/spanner-pg/.prettierrc.cjs b/handwritten/spanner-pg/.prettierrc.cjs new file mode 100644 index 000000000000..d2eddc2ed894 --- /dev/null +++ b/handwritten/spanner-pg/.prettierrc.cjs @@ -0,0 +1,17 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +module.exports = { + ...require('gts/.prettierrc.json') +} diff --git a/handwritten/spanner-pg/package.json b/handwritten/spanner-pg/package.json new file mode 100644 index 000000000000..cf051e583952 --- /dev/null +++ b/handwritten/spanner-pg/package.json @@ -0,0 +1,63 @@ +{ + "name": "@google-cloud/spanner-pg", + "version": "0.1.0", + "description": "Node.js pg-compatible driver wrapper for Google Cloud Spanner", + "repository": { + "type": "git", + "url": "https://github.com/googleapis/google-cloud-node.git", + "directory": "handwritten/spanner-pg" + }, + "license": "Apache-2.0", + "author": "Google LLC", + "main": "./build/cjs/src/index.cjs", + "module": "./build/esm/src/index.js", + "types": "./build/cjs/src/index.d.ts", + "type": "module", + "exports": { + ".": { + "import": { + "types": "./build/esm/src/index.d.ts", + "default": "./build/esm/src/index.js" + }, + "require": { + "types": "./build/cjs/src/index.d.ts", + "default": "./build/cjs/src/index.cjs" + } + } + }, + "files": [ + "build/esm/src", + "build/cjs/src" + ], + "scripts": { + "clean": "gts clean", + "compile:esm": "tsc -p .", + "compile:cjs": "tsc -p ./tsconfig.cjs.json && babel build/cjs --out-dir build/cjs --out-file-extension .cjs && node scripts/fix-extensions.cjs", + "compile": "npm run compile:esm && npm run compile:cjs", + "fix": "gts fix", + "lint": "gts check", + "prepare": "npm run compile", + "test:esm": "c8 mocha build/esm/test/**/*.js", + "test:cjs": "c8 mocha build/cjs/test/**/*.cjs", + "test": "npm run test:esm && npm run test:cjs" + }, + "dependencies": { + "spannerlib-node": "file:/Users/gargsurbhi/Work/Github/go-sql-spanner/spannerlib/wrappers/spannerlib-node", + "@google-cloud/spanner": "^8.7.1" + }, + "devDependencies": { + "@types/mocha": "^10.0.10", + "@types/node": "^22.13.9", + "@types/sinon": "^17.0.4", + "c8": "^10.1.3", + "gts": "^6.0.2", + "mocha": "^11.1.0", + "sinon": "^21.0.3", + "typescript": "^5.8.2", + "@babel/core": "^7.24.0", + "@babel/cli": "^7.23.9" + }, + "engines": { + "node": ">=18" + } +} diff --git a/handwritten/spanner-pg/scripts/fix-extensions.cjs b/handwritten/spanner-pg/scripts/fix-extensions.cjs new file mode 100644 index 000000000000..9fd1428d6e4a --- /dev/null +++ b/handwritten/spanner-pg/scripts/fix-extensions.cjs @@ -0,0 +1,42 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +const fs = require('fs'); +const path = require('path'); + +const cjsDir = path.join(__dirname, '../build/cjs'); + +function traverseDir(dir) { + fs.readdirSync(dir).forEach(file => { + const fullPath = path.join(dir, file); + if (fs.statSync(fullPath).isDirectory()) { + traverseDir(fullPath); + } else if (fullPath.endsWith('.cjs')) { + let content = fs.readFileSync(fullPath, 'utf8'); + // Replace require paths starting with . / or .. / and ending with .js + content = content.replace(/require\(['"]((\.|\.\.)\/[^'"]+)\.js['"]\)/g, "require('$1.cjs')"); + // Fix import.meta.url syntax error in CommonJS + content = content.replace(/import\.meta\.url/g, '""'); + fs.writeFileSync(fullPath, content); + } else if (fullPath.endsWith('.js')) { + // Delete the original .js file generated by tsc + fs.unlinkSync(fullPath); + } + }); +} + +if (fs.existsSync(cjsDir)) { + traverseDir(cjsDir); + console.log('Fixed extensions in .cjs files and removed .js files in build/cjs'); +} diff --git a/handwritten/spanner-pg/src/index.ts b/handwritten/spanner-pg/src/index.ts new file mode 100644 index 000000000000..bf825d52484c --- /dev/null +++ b/handwritten/spanner-pg/src/index.ts @@ -0,0 +1,51 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import {Client} from './lib/client.js'; +import {Pool} from './lib/pool.js'; +import {Query} from './lib/query.js'; +import {types} from './lib/types.js'; + +export {Client, Pool, Query, types}; +export const native = {Client, Pool}; + +export const defaults = { + host: 'localhost', + port: 5432, + user: 'postgres', + password: '', + database: 'postgres', +}; + +export class DatabaseError extends Error { + severity?: string; + code?: string; + detail?: string; + hint?: string; + position?: string; + internalPosition?: string; + internalQuery?: string; + where?: string; + schema?: string; + table?: string; + column?: string; + dataType?: string; + constraint?: string; + file?: string; + line?: string; + routine?: string; +} + +export const escapeIdentifier = (str: string) => `"${str.replace(/"/g, '""')}"`; +export const escapeLiteral = (str: string) => `'${str.replace(/'/g, "''")}'`; diff --git a/handwritten/spanner-pg/src/lib/client.ts b/handwritten/spanner-pg/src/lib/client.ts new file mode 100644 index 000000000000..7573d922351e --- /dev/null +++ b/handwritten/spanner-pg/src/lib/client.ts @@ -0,0 +1,390 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import {EventEmitter} from 'events'; +import {resolveDsn} from './config.js'; +import {encodeValue, decodeValue, getPgOid} from './codec.js'; +import {Pool as SpannerPool, Connection} from 'spannerlib-node'; +import {Query} from './query.js'; +import {enrichPgError} from './errors.js'; + +export interface ClientConfig { + connectionString?: string; + host?: string; + port?: number; + database?: string; + user?: string; + password?: string; + ssl?: boolean | any; + project?: string; + instance?: string; +} + +export interface FieldDef { + name: string; + dataTypeID: number; +} + +export interface QueryResult { + rows: any[]; + fields: FieldDef[]; + rowCount: number; + command: string; +} + +export interface QueryConfig { + text: string; + values?: any[]; + rowMode?: 'array'; +} + +export class Client extends EventEmitter { + static Query = Query; + private config: ClientConfig; + private connection: Connection | null = null; + private spannerPool: SpannerPool | null = null; + private isConnected = false; + private externalConnection = false; + private queryQueue: Array<{run: () => Promise}> = []; + private isExecuting = false; + private ending = false; + + constructor(config?: string | ClientConfig) { + super(); + if (typeof config === 'string') { + this.config = {connectionString: config}; + } else { + this.config = config || {}; + } + } + + /** + * Internal helper to inject an existing Connection (used by Pool client checkout). + */ + setConnection(conn: Connection): void { + this.connection = conn; + this.isConnected = true; + this.externalConnection = true; + } + + /** + * Stubs custom client-level type parsing overrides. + */ + setTypeParser(id: number, parser: Function): void {} + + /** + * Establishes a session pool and checkout connection. + */ + async connect(): Promise; + async connect(callback: (err?: Error) => void): Promise; + async connect(callback?: (err?: Error) => void): Promise { + if (this.isConnected) { + if (callback) callback(); + return; + } + + try { + const dsn = resolveDsn(this.config); + this.spannerPool = await SpannerPool.create(dsn); + this.connection = await this.spannerPool.createConnection(); + this.isConnected = true; + this.emit('connect'); + if (callback) callback(); + } catch (err: any) { + err = enrichPgError(err); + this.emit('error', err); + if (callback) { + callback(err); + return; + } + throw err; + } + } + + /** + * Closes active connection and pool handles. + */ + private async doClose(): Promise { + if (this.connection && !this.externalConnection) { + await this.connection.close(); + this.connection = null; + } + if (this.spannerPool && !this.externalConnection) { + await this.spannerPool.close(); + this.spannerPool = null; + } + this.isConnected = false; + this.emit('end'); + } + + async end(): Promise; + async end(callback: (err?: Error) => void): Promise; + async end(callback?: (err?: Error) => void): Promise { + this.ending = true; + const endPromise = new Promise((resolve, reject) => { + if (this.queryQueue.length === 0 && !this.isExecuting) { + this.doClose().then(resolve, reject); + } else { + this.once('drain', () => { + this.doClose().then(resolve, reject); + }); + } + }); + + try { + await endPromise; + if (callback) callback(); + } catch (err: any) { + if (callback) { + callback(err); + return; + } + throw err; + } + } + + /** + * Executes a query with optional parameters, returning a formatted QueryResult. + */ + query(text: string, values?: any[]): Query; + query( + text: string, + callback: (err: Error | null, result?: QueryResult) => void, + ): Query; + query( + text: string, + values: any[], + callback: (err: Error | null, result?: QueryResult) => void, + ): Query; + query(config: QueryConfig): Query; + query( + config: QueryConfig, + callback: (err: Error | null, result?: QueryResult) => void, + ): Query; + query(query: Query): Query; + query( + text: string | QueryConfig | Query, + values?: any[] | ((err: Error | null, result?: QueryResult) => void), + callback?: (err: Error | null, result?: QueryResult) => void, + ): Query { + const query = + text instanceof Query ? text : new Query(text, values as any, callback); + + if (this.ending) { + const err = new Error('Client was closed and is not queryable'); + const executionPromise = Promise.reject(err); + if (query.callback) { + executionPromise.catch(() => {}); + } + query.setPromise(executionPromise); + process.nextTick(() => { + const actualCallback = query.callback; + if (actualCallback) { + actualCallback(err); + } else { + query.emit('error', err); + } + }); + return query; + } + + const task = { + run: async () => { + const sqlText = query.text; + const sqlValues = query.values; + const actualCallback = query.callback; + const isArrayMode = query.rowMode === 'array'; + let rows: any; + try { + if (!this.isConnected) { + await this.connect(); + } + + // 1. Build Spanner parameter maps from positional args + const params: Record = {}; + const paramTypes: Record = {}; + if (sqlValues && sqlValues.length > 0) { + for (let i = 0; i < sqlValues.length; i++) { + const paramName = `p${i + 1}`; + const {valueProto, typeProto} = encodeValue(sqlValues[i]); + params[paramName] = valueProto; + paramTypes[paramName] = typeProto; + } + } + + // 2. Execute on Go wrapper connection + const executeRequest: any = {sql: sqlText}; + if (sqlValues && sqlValues.length > 0) { + executeRequest.params = {fields: params}; + executeRequest.paramTypes = paramTypes; + } + + rows = await this.connection!.execute(executeRequest); + + // 3. Extract columns metadata + const metadata = await rows.metadata(); + const fields: FieldDef[] = []; + if (metadata && metadata.rowType && metadata.rowType.fields) { + for (const f of metadata.rowType.fields) { + fields.push({ + name: f.name || '', + dataTypeID: f.type ? getPgOid(f.type) : 0, + }); + } + } + + // 4. Decode results rows + const outputRows: any[] = []; + let listValue; + while ((listValue = await rows.next()) !== null) { + let rowData: any; + if (isArrayMode) { + rowData = []; + if ( + listValue.values && + metadata && + metadata.rowType && + metadata.rowType.fields + ) { + for ( + let colIdx = 0; + colIdx < listValue.values.length; + colIdx++ + ) { + const field: any = metadata.rowType.fields[colIdx]; + const fieldType: any = field.type; + const valProto: any = listValue.values[colIdx]; + rowData.push(decodeValue(valProto, fieldType)); + } + } + } else { + rowData = {}; + if ( + listValue.values && + metadata && + metadata.rowType && + metadata.rowType.fields + ) { + for ( + let colIdx = 0; + colIdx < listValue.values.length; + colIdx++ + ) { + const field: any = metadata.rowType.fields[colIdx]; + const fieldName: string = field.name || ''; + const fieldType: any = field.type; + const valProto: any = listValue.values[colIdx]; + rowData[fieldName] = decodeValue(valProto, fieldType); + } + } + } + + outputRows.push(rowData); + query.emit('row', rowData); + } + + // 5. Query stats & row count mapping + let rowCount = outputRows.length; + const stats = await rows.resultSetStats(); + if ( + stats && + stats.rowCountExact !== undefined && + stats.rowCountExact !== null + ) { + rowCount = + typeof stats.rowCountExact === 'number' + ? stats.rowCountExact + : parseInt(stats.rowCountExact.toString(), 10); + } + + // Clean up iterator + await rows.close(); + + // Inferred PG command + let command = 'SELECT'; + const trimmedSql = sqlText.trim().toUpperCase(); + if (trimmedSql.startsWith('INSERT')) command = 'INSERT'; + else if (trimmedSql.startsWith('UPDATE')) command = 'UPDATE'; + else if (trimmedSql.startsWith('DELETE')) command = 'DELETE'; + else if (trimmedSql.startsWith('CREATE')) command = 'CREATE'; + else if (trimmedSql.startsWith('DROP')) command = 'DROP'; + + const result: QueryResult = { + rows: outputRows, + fields, + rowCount, + command, + }; + + query.emit('end', result); + if (actualCallback) { + process.nextTick(() => actualCallback(null, result as any)); + } + return result; + } catch (err: any) { + err = enrichPgError(err); + if (rows) { + await rows.close().catch(() => {}); + } + if (query.listenerCount('error') > 0) { + query.emit('error', err); + } + if (actualCallback) { + process.nextTick(() => actualCallback(err)); + } + throw err; + } + }, + }; + + const executionPromise = new Promise((resolve, reject) => { + const originalRun = task.run; + task.run = async () => { + try { + const res = await originalRun(); + resolve(res); + return res; + } catch (err: any) { + err = enrichPgError(err); + reject(err); + throw err; + } + }; + }); + + query.setPromise(executionPromise); + this.queryQueue.push(task); + void this.processQueue(); + return query; + } + + private async processQueue(): Promise { + if (this.queryQueue.length === 0) { + this.emit('drain'); + return; + } + if (this.isExecuting) return; + this.isExecuting = true; + const task = this.queryQueue[0]; + try { + await task.run(); + } catch (err: any) { + // Ignored here, handles in executionPromise reject + } finally { + this.queryQueue.shift(); + this.isExecuting = false; + void this.processQueue(); + } + } +} diff --git a/handwritten/spanner-pg/src/lib/codec.ts b/handwritten/spanner-pg/src/lib/codec.ts new file mode 100644 index 000000000000..598f1427ce27 --- /dev/null +++ b/handwritten/spanner-pg/src/lib/codec.ts @@ -0,0 +1,489 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import * as pkg from '@google-cloud/spanner/build/protos/protos.js'; +const google = pkg.google || (pkg as any).default?.google; +const TypeCode = google.spanner.v1.TypeCode; + +export interface EncodedParam { + valueProto: any; + typeProto: any; +} + +/** + * Standard PostgreSQL Type OIDs. + */ +export const PgOid = { + BOOL: 16, + BYTEA: 17, + INT8: 20, + INT2: 21, + INT4: 23, + TEXT: 25, + JSON: 114, + FLOAT4: 700, + FLOAT8: 701, + VARCHAR: 1043, + DATE: 1082, + TIMESTAMP: 1114, + TIMESTAMPTZ: 1184, + NUMERIC: 1700, + UUID: 2950, + JSONB: 3802, + + // Array OIDs + BOOL_ARRAY: 1000, + BYTEA_ARRAY: 1001, + INT2_ARRAY: 1005, + INT4_ARRAY: 1007, + TEXT_ARRAY: 1009, + VARCHAR_ARRAY: 1015, + INT8_ARRAY: 1016, + FLOAT4_ARRAY: 1021, + FLOAT8_ARRAY: 1022, + TIMESTAMP_ARRAY: 1115, + DATE_ARRAY: 1182, + TIMESTAMPTZ_ARRAY: 1185, + NUMERIC_ARRAY: 1231, + JSONB_ARRAY: 3807, +}; + +const PgOidToSpannerTypeMapping = new Map([ + [PgOid.BOOL, {code: TypeCode.BOOL}], + [PgOid.BYTEA, {code: TypeCode.BYTES}], + [PgOid.INT2, {code: TypeCode.INT64}], + [PgOid.INT4, {code: TypeCode.INT64}], + [PgOid.INT8, {code: TypeCode.INT64}], + [PgOid.TEXT, {code: TypeCode.STRING}], + [PgOid.VARCHAR, {code: TypeCode.STRING}], + [PgOid.JSON, {code: TypeCode.JSON}], + [PgOid.JSONB, {code: TypeCode.JSON}], + [PgOid.FLOAT4, {code: TypeCode.FLOAT64}], + [PgOid.FLOAT8, {code: TypeCode.FLOAT64}], + [PgOid.DATE, {code: TypeCode.DATE}], + [PgOid.TIMESTAMP, {code: TypeCode.TIMESTAMP}], + [PgOid.TIMESTAMPTZ, {code: TypeCode.TIMESTAMP}], + [PgOid.NUMERIC, {code: TypeCode.STRING}], + [PgOid.UUID, {code: TypeCode.STRING}], + + // Arrays + [ + PgOid.BOOL_ARRAY, + {code: TypeCode.ARRAY, arrayElementType: {code: TypeCode.BOOL}}, + ], + [ + PgOid.BYTEA_ARRAY, + {code: TypeCode.ARRAY, arrayElementType: {code: TypeCode.BYTES}}, + ], + [ + PgOid.INT2_ARRAY, + {code: TypeCode.ARRAY, arrayElementType: {code: TypeCode.INT64}}, + ], + [ + PgOid.INT4_ARRAY, + {code: TypeCode.ARRAY, arrayElementType: {code: TypeCode.INT64}}, + ], + [ + PgOid.INT8_ARRAY, + {code: TypeCode.ARRAY, arrayElementType: {code: TypeCode.INT64}}, + ], + [ + PgOid.TEXT_ARRAY, + {code: TypeCode.ARRAY, arrayElementType: {code: TypeCode.STRING}}, + ], + [ + PgOid.VARCHAR_ARRAY, + {code: TypeCode.ARRAY, arrayElementType: {code: TypeCode.STRING}}, + ], + [ + PgOid.FLOAT4_ARRAY, + {code: TypeCode.ARRAY, arrayElementType: {code: TypeCode.FLOAT64}}, + ], + [ + PgOid.FLOAT8_ARRAY, + {code: TypeCode.ARRAY, arrayElementType: {code: TypeCode.FLOAT64}}, + ], + [ + PgOid.TIMESTAMP_ARRAY, + {code: TypeCode.ARRAY, arrayElementType: {code: TypeCode.TIMESTAMP}}, + ], + [ + PgOid.DATE_ARRAY, + {code: TypeCode.ARRAY, arrayElementType: {code: TypeCode.DATE}}, + ], + [ + PgOid.TIMESTAMPTZ_ARRAY, + {code: TypeCode.ARRAY, arrayElementType: {code: TypeCode.TIMESTAMP}}, + ], + [ + PgOid.NUMERIC_ARRAY, + {code: TypeCode.ARRAY, arrayElementType: {code: TypeCode.STRING}}, + ], + [ + PgOid.JSONB_ARRAY, + {code: TypeCode.ARRAY, arrayElementType: {code: TypeCode.JSON}}, + ], +]); + +/** + * Maps a PostgreSQL Type OID to a Spanner Protobuf Type descriptor. + */ +export function getSpannerType(oid?: number): any | null { + if (oid === undefined || oid === null) return null; + return PgOidToSpannerTypeMapping.get(oid) || null; +} + +/** + * Maps a Spanner Protobuf Type descriptor to a PostgreSQL Type OID. + */ +export function getPgOid(typeProto: any): number { + if (!typeProto) return PgOid.TEXT; + let code = typeProto.code; + if (typeof code === 'string') { + code = (TypeCode as any)[code]; + } + switch (code) { + case TypeCode.BOOL: + return PgOid.BOOL; + case TypeCode.INT64: + return PgOid.INT8; + case TypeCode.FLOAT64: + return PgOid.FLOAT8; + case TypeCode.STRING: + return PgOid.TEXT; + case TypeCode.DATE: + return PgOid.DATE; + case TypeCode.TIMESTAMP: + return PgOid.TIMESTAMPTZ; + case TypeCode.BYTES: + return PgOid.BYTEA; + case TypeCode.JSON: + return PgOid.JSONB; + case TypeCode.ARRAY: { + let elemCode = typeProto.arrayElementType?.code; + if (typeof elemCode === 'string') { + elemCode = (TypeCode as any)[elemCode]; + } + switch (elemCode) { + case TypeCode.BOOL: + return PgOid.BOOL_ARRAY; + case TypeCode.INT64: + return PgOid.INT8_ARRAY; + case TypeCode.FLOAT64: + return PgOid.FLOAT8_ARRAY; + case TypeCode.STRING: + return PgOid.TEXT_ARRAY; + case TypeCode.DATE: + return PgOid.DATE_ARRAY; + case TypeCode.TIMESTAMP: + return PgOid.TIMESTAMPTZ_ARRAY; + case TypeCode.BYTES: + return PgOid.BYTEA_ARRAY; + case TypeCode.JSON: + return PgOid.JSONB_ARRAY; + default: + return PgOid.TEXT_ARRAY; + } + } + default: + return PgOid.TEXT; + } +} + +export function getSpannerTypeCodeFromOid(oid: number): string { + switch (oid) { + case PgOid.BOOL: + return 'BOOL'; + case PgOid.INT8: + case PgOid.INT4: + case PgOid.INT2: + return 'INT64'; + case PgOid.FLOAT8: + case PgOid.FLOAT4: + return 'FLOAT64'; + case PgOid.DATE: + return 'DATE'; + case PgOid.TIMESTAMPTZ: + case PgOid.TIMESTAMP: + return 'TIMESTAMP'; + case PgOid.BYTEA: + return 'BYTES'; + case PgOid.JSONB: + case PgOid.JSON: + return 'JSON'; + default: + return 'STRING'; + } +} + +/** + * Parses a PostgreSQL array string literal (e.g. '{1, 2, 3}' or '{"a", "b"}') into a JS Array. + */ +export function parsePgArrayLiteral(str: string): any[] | null { + const trimmed = str.trim(); + if (!trimmed.startsWith('{') || !trimmed.endsWith('}')) { + return null; + } + const inner = trimmed.slice(1, -1).trim(); + if (inner.length === 0) { + return []; + } + const result: any[] = []; + let current = ''; + let inQuotes = false; + let escapeNext = false; + + for (let i = 0; i < inner.length; i++) { + const char = inner[i]; + if (!inQuotes && (char === ' ' || char === '\t') && current.length === 0) { + continue; + } + if (escapeNext) { + current += char; + escapeNext = false; + continue; + } + if (char === '\\') { + escapeNext = true; + continue; + } + if (char === '"') { + inQuotes = !inQuotes; + continue; + } + if (char === ',' && !inQuotes) { + result.push(convertPgArrayToken(current)); + current = ''; + continue; + } + current += char; + } + result.push(convertPgArrayToken(current)); + return result; +} + +function convertPgArrayToken(token: string): any { + const trimmed = token.trim(); + if (trimmed.toUpperCase() === 'NULL') { + return null; + } + if (/^-?\d+$/.test(trimmed)) { + return parseInt(trimmed, 10); + } + if (/^-?\d+\.\d+$/.test(trimmed)) { + return parseFloat(trimmed); + } + return token; +} + +/** + * Encodes a JavaScript value to Spanner Protobuf value and type format. + */ +export function encodeValue(val: any, oid?: number): EncodedParam { + if (typeof val === 'string' && val.startsWith('{') && val.endsWith('}')) { + const parsedArray = parsePgArrayLiteral(val); + if (parsedArray !== null) { + return encodeValue(parsedArray); + } + } + + const explicitType = getSpannerType(oid); + if (val === null || val === undefined) { + return { + valueProto: {nullValue: 0}, + typeProto: explicitType || {code: TypeCode.STRING}, + }; + } + + if (typeof val === 'string') { + return { + valueProto: {stringValue: val}, + typeProto: explicitType || {code: TypeCode.STRING}, + }; + } + + if (typeof val === 'boolean') { + return { + valueProto: {boolValue: val}, + typeProto: explicitType || {code: TypeCode.BOOL}, + }; + } + + if (typeof val === 'number') { + if (Number.isInteger(val)) { + return { + valueProto: {stringValue: val.toString()}, + typeProto: {code: TypeCode.INT64}, + }; + } else { + return { + valueProto: {numberValue: val}, + typeProto: {code: TypeCode.FLOAT64}, + }; + } + } + + if (val instanceof Date) { + return { + valueProto: {stringValue: val.toISOString()}, + typeProto: {code: TypeCode.TIMESTAMP}, + }; + } + + if (Buffer.isBuffer(val)) { + return { + valueProto: {stringValue: val.toString('base64')}, + typeProto: {code: TypeCode.BYTES}, + }; + } + + if (Array.isArray(val)) { + if (val.length === 0) { + return { + valueProto: {listValue: {values: []}}, + typeProto: { + code: TypeCode.ARRAY, + arrayElementType: {code: TypeCode.TYPE_CODE_UNSPECIFIED}, + }, + }; + } + const encodedElements = val.map(encodeValue); + const elementTypeProto = encodedElements[0].typeProto; + return { + valueProto: { + listValue: {values: encodedElements.map(el => el.valueProto)}, + }, + typeProto: { + code: TypeCode.ARRAY, + arrayElementType: elementTypeProto, + }, + }; + } + + if (typeof val === 'object') { + return { + valueProto: {stringValue: JSON.stringify(val)}, + typeProto: {code: TypeCode.JSON}, + }; + } + + throw new Error( + `Unsupported type for Spanner parameter encoding: ${typeof val}`, + ); +} + +let customParserHook: + | ((oid: number) => ((val: string) => any) | null) + | null = null; + +export function registerCustomParserHook( + hook: (oid: number) => ((val: string) => any) | null +): void { + customParserHook = hook; +} + +/** + * Decodes a Spanner Protobuf value to a JavaScript native value. + */ +export function decodeValue( + valProto: any, + typeProto: any, + applyCustomParsers = true +): any { + if (!valProto) { + return null; + } + + const kind = valProto.kind; + if (kind === 'nullValue') { + return null; + } + + // Extract raw value based on active oneof kind, with plain-object fallback + let rawVal: any; + if (kind !== undefined) { + rawVal = valProto[kind]; + } else { + if (valProto.stringValue !== undefined) rawVal = valProto.stringValue; + else if (valProto.numberValue !== undefined) rawVal = valProto.numberValue; + else if (valProto.boolValue !== undefined) rawVal = valProto.boolValue; + else if (valProto.listValue !== undefined) rawVal = valProto.listValue; + else if (valProto.nullValue !== undefined) return null; + else rawVal = valProto; // fallback + } + + if (applyCustomParsers && customParserHook) { + const oid = getPgOid(typeProto); + const customParser = customParserHook(oid); + if (customParser && rawVal !== null && rawVal !== undefined) { + return customParser(String(rawVal)); + } + } + + let code = typeProto?.code; + if (typeof code === 'string' && (TypeCode as any)[code] !== undefined) { + code = (TypeCode as any)[code]; + } + + if ( + code === 'BOOL' || + code === TypeCode?.BOOL || + code === 1 + ) { + if (typeof rawVal === 'string') { + return rawVal === 't' || rawVal === 'true' || rawVal === '1'; + } + return rawVal; + } + + switch (code) { + + case TypeCode.INT64: + return rawVal; + + case TypeCode.FLOAT64: + return rawVal; + + case TypeCode.STRING: + case TypeCode.NUMERIC: + return rawVal; + + case TypeCode.DATE: + return rawVal; + + case TypeCode.TIMESTAMP: + return new Date(rawVal); + + case TypeCode.BYTES: + return Buffer.from(rawVal, 'base64'); + + case TypeCode.JSON: + try { + return JSON.parse(rawVal); + } catch { + return rawVal; + } + + case TypeCode.ARRAY: { + if (!rawVal || !rawVal.values) return []; + const elemType = typeProto.arrayElementType || { + code: TypeCode.TYPE_CODE_UNSPECIFIED, + }; + return rawVal.values.map((v: any) => decodeValue(v, elemType)); + } + + default: + return rawVal; + } +} diff --git a/handwritten/spanner-pg/src/lib/config.ts b/handwritten/spanner-pg/src/lib/config.ts new file mode 100644 index 000000000000..dc16f2e5d4ea --- /dev/null +++ b/handwritten/spanner-pg/src/lib/config.ts @@ -0,0 +1,123 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import {ClientConfig} from './client.js'; + +/** + * Resolves a pg-compatible config or connection string into a Spanner-compatible DSN. + */ +export function resolveDsn(config?: string | ClientConfig): string { + if ( + !config || + (typeof config === 'object' && !config.database && !config.connectionString) + ) { + const pgConnStr = process.env.DATABASE_URL || process.env.PGCONNECTSTRING; + if (pgConnStr) { + return parseConnectionString(pgConnStr); + } + if (!config) { + throw new Error('No connection configuration specified'); + } + } + + if (typeof config === 'string') { + return parseConnectionString(config); + } + + if (config.connectionString) { + return parseConnectionString(config.connectionString); + } + + // Build DSN from parts + let dbPath = ''; + if (config.database) { + if ( + config.database.startsWith('postgresql://') || + config.database.startsWith('postgres://') + ) { + dbPath = parseConnectionString(config.database); + } else if (config.database.includes('projects/')) { + dbPath = config.database; + } else if (config.project && config.instance) { + dbPath = `projects/${config.project}/instances/${config.instance}/databases/${config.database}`; + } else { + throw new Error( + 'Database must be a full resource path or project/instance must be specified', + ); + } + } else { + throw new Error('Database name not specified'); + } + + if (dbPath.startsWith('/')) { + dbPath = dbPath.substring(1); + } + + const queryParams: string[] = []; + + if (config.host) { + const port = config.port || 5432; + if (!(config.host === 'localhost' && port === 5432)) { + queryParams.push(`api_endpoint=${config.host}:${port}`); + } + } + + if (process.env.SPANNER_EMULATOR_HOST) { + queryParams.push('auto_config_emulator=true'); + } + + if (queryParams.length > 0) { + return `${dbPath}?${queryParams.join('&')}`; + } + + return dbPath; +} + +function parseConnectionString(connStr: string): string { + if ( + connStr.startsWith('postgresql://') || + connStr.startsWith('postgres://') + ) { + const url = new URL(connStr); + let dbPath = url.pathname; + if (dbPath.startsWith('/')) { + dbPath = dbPath.substring(1); + } + + if (!dbPath.includes('projects/')) { + throw new Error( + `Invalid Spanner database path in connection URL: ${url.pathname}. Expected format: projects/PROJECT/instances/INSTANCE/databases/DATABASE`, + ); + } + + const queryParams: string[] = []; + url.searchParams.forEach((value, key) => { + queryParams.push(`${key}=${value}`); + }); + + if ( + process.env.SPANNER_EMULATOR_HOST && + !url.searchParams.has('auto_config_emulator') + ) { + queryParams.push('auto_config_emulator=true'); + } + + if (queryParams.length > 0) { + return `${dbPath}?${queryParams.join('&')}`; + } + return dbPath; + } + + return connStr; +} diff --git a/handwritten/spanner-pg/src/lib/errors.ts b/handwritten/spanner-pg/src/lib/errors.ts new file mode 100644 index 000000000000..8de199bfe38c --- /dev/null +++ b/handwritten/spanner-pg/src/lib/errors.ts @@ -0,0 +1,137 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +export interface PgError extends Error { + code?: string; + severity?: string; + detail?: string; + hint?: string; + position?: string | number; +} + +/** + * Enriches a thrown Error from Spanner or SpannerLib with standard node-postgres (pg) + * SQLSTATE codes (err.code) and severity fields so ORMs and applications matching + * on PostgreSQL error codes function correctly. + */ +export function enrichPgError(err: any): PgError { + if (!err) { + err = new Error('Unknown error'); + } + + const pgErr = err as PgError; + + // Set default severity + if (!pgErr.severity) { + pgErr.severity = 'ERROR'; + } + + // If code is already a 5-char SQLSTATE string, preserve it + if (typeof pgErr.code === 'string' && /^[0-9A-Z]{5}$/i.test(pgErr.code)) { + return pgErr; + } + + const msg = (pgErr.message || '').toString(); + + // 1. Undefined Table (42P01) + if ( + /relation ".*" does not exist/i.test(msg) || + /Table not found/i.test(msg) || + /Table .* does not exist/i.test(msg) + ) { + pgErr.code = '42P01'; + return pgErr; + } + + // 2. Undefined Column (42703) + if ( + /column ".*" does not exist/i.test(msg) || + /Column .* not found/i.test(msg) || + /Column name not valid/i.test(msg) + ) { + pgErr.code = '42703'; + return pgErr; + } + + // 3. Unique Violation / Duplicate Key (23505) + if ( + /already exists/i.test(msg) || + /duplicate key value violates unique constraint/i.test(msg) || + /ALREADY_EXISTS/i.test(msg) + ) { + pgErr.code = '23505'; + return pgErr; + } + + // 4. Syntax Error (42601) + if ( + /syntax error/i.test(msg) || + /failed to parse/i.test(msg) || + /parse error/i.test(msg) + ) { + pgErr.code = '42601'; + return pgErr; + } + + // 5. Invalid Input Syntax / Type Mismatch (22P02) + if ( + /invalid input syntax/i.test(msg) || + /cannot be cast/i.test(msg) || + /is of type .* but expression is of type/i.test(msg) + ) { + pgErr.code = '22P02'; + return pgErr; + } + + // 6. Division by Zero (22012) + if (/division by zero/i.test(msg)) { + pgErr.code = '22012'; + return pgErr; + } + + // 7. Numeric Value Out of Range (22003) + if (/out of range/i.test(msg) || /overflow/i.test(msg)) { + pgErr.code = '22003'; + return pgErr; + } + + // 8. Query / Statement Timeout or Deadline Exceeded (57014) + if ( + /timeout/i.test(msg) || + /DEADLINE_EXCEEDED/i.test(msg) || + /canceled/i.test(msg) + ) { + pgErr.code = '57014'; + return pgErr; + } + + // 9. Serialization Failure / Transaction Aborted (40001) + if (/aborted/i.test(msg) || /ABORTED/i.test(msg)) { + pgErr.code = '40001'; + return pgErr; + } + + // 10. Connection Failure (08006) + if (/connection/i.test(msg)) { + pgErr.code = '08006'; + return pgErr; + } + + // Default fallback SQLSTATE internal error + if (!pgErr.code) { + pgErr.code = 'XX000'; + } + + return pgErr; +} diff --git a/handwritten/spanner-pg/src/lib/pool.ts b/handwritten/spanner-pg/src/lib/pool.ts new file mode 100644 index 000000000000..d0cfe56abdb2 --- /dev/null +++ b/handwritten/spanner-pg/src/lib/pool.ts @@ -0,0 +1,154 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import {EventEmitter} from 'events'; +import {resolveDsn} from './config.js'; +import {Client, ClientConfig, QueryConfig, QueryResult} from './client.js'; +import {Pool as SpannerPool, Connection} from 'spannerlib-node'; + +export interface PoolClient extends Client { + release(err?: Error | boolean): void; +} + +export class Pool extends EventEmitter { + private config: ClientConfig; + private spannerPool: SpannerPool | null = null; + private isClosed = false; + + constructor(config?: string | ClientConfig) { + super(); + if (typeof config === 'string') { + this.config = {connectionString: config}; + } else { + this.config = config || {}; + } + } + + private async ensurePool(): Promise { + if (this.isClosed) { + throw new Error('Pool is already closed'); + } + if (this.spannerPool) { + return this.spannerPool; + } + const dsn = resolveDsn(this.config); + this.spannerPool = await SpannerPool.create(dsn); + return this.spannerPool; + } + + /** + * Checks out a client connection from the pool. + */ + async connect(): Promise; + async connect( + callback: (err?: Error, client?: PoolClient, done?: () => void) => void, + ): Promise; + async connect( + callback?: (err?: Error, client?: PoolClient, done?: () => void) => void, + ): Promise { + try { + const pool = await this.ensurePool(); + const conn = await pool.createConnection(); + + // Wrap in Client instance + const client = new Client(this.config) as unknown as PoolClient; + client.setConnection(conn); + + // Add release hook + let released = false; + client.release = (err?: Error | boolean) => { + if (released) return; + released = true; + conn.close().catch(closeErr => { + this.emit('error', closeErr); + }); + }; + + this.emit('connect', client); + this.emit('acquire', client); + + if (callback) { + callback(undefined, client, () => client.release()); + } + return client; + } catch (err: any) { + if (callback) { + callback(err, undefined as any, (() => {}) as any); + return undefined as any; + } + throw err; + } + } + + /** + * Helper executing a query immediately on a checked out client. + */ + async query(text: string, values?: any[]): Promise; + async query(config: QueryConfig): Promise; + async query( + text: string | QueryConfig, + values?: any[], + callback?: (err: Error | null, result?: QueryResult) => void, + ): Promise { + let actualCallback = callback; + if (typeof values === 'function') { + actualCallback = values as any; + values = undefined; + } + + let client: PoolClient | null = null; + try { + client = await this.connect(); + const res = await (client as any).query(text, values); + client.release(); + if (actualCallback) { + actualCallback(null, res); + } + return res; + } catch (err: any) { + if (client) { + client.release(); + } + if (actualCallback) { + actualCallback(err); + } + throw err; + } + } + + /** + * Closes the connection pool. + */ + async end(): Promise; + async end(callback: () => void): Promise; + async end(callback?: () => void): Promise { + try { + this.isClosed = true; + if (this.spannerPool) { + await this.spannerPool.close(); + this.spannerPool = null; + } + if (callback) { + callback(); + } + } catch (err: any) { + this.emit('error', err); + if (callback) { + callback(); + return; + } + throw err; + } + } +} diff --git a/handwritten/spanner-pg/src/lib/query.ts b/handwritten/spanner-pg/src/lib/query.ts new file mode 100644 index 000000000000..331e90ae4a5d --- /dev/null +++ b/handwritten/spanner-pg/src/lib/query.ts @@ -0,0 +1,74 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import {EventEmitter} from 'events'; +import {QueryConfig} from './client.js'; + +export class Query extends EventEmitter { + public text: string; + public values?: any[]; + public callback?: (err: Error | null, result?: any) => void; + public rowMode?: 'array'; + private promise!: Promise; + + constructor( + text: string | QueryConfig | Query, + values?: any[] | ((err: Error | null, result?: any) => void), + callback?: (err: Error | null, result?: any) => void, + ) { + super(); + + if (text instanceof Query) { + this.text = text.text; + this.values = text.values; + this.callback = text.callback; + this.rowMode = text.rowMode; + return; + } + + if (typeof text === 'object') { + this.text = text.text; + this.values = text.values; + this.rowMode = text.rowMode; + this.callback = + typeof values === 'function' ? (values as any) : (callback as any); + } else { + this.text = text; + if (typeof values === 'function') { + this.callback = values as any; + this.values = undefined; + } else { + this.values = values; + this.callback = callback as any; + } + } + } + + /** + * Implements the thenable interface to support async/await transparently. + */ + public then( + onFulfilled?: (value: any) => any, + onRejected?: (reason: any) => any, + ): Promise { + return this.promise.then(onFulfilled, onRejected); + } + + /** + * Internal setter to link the query execution task to the thenable promise. + */ + public setPromise(promise: Promise): void { + this.promise = promise; + } +} diff --git a/handwritten/spanner-pg/src/lib/types.ts b/handwritten/spanner-pg/src/lib/types.ts new file mode 100644 index 000000000000..38ebb082817b --- /dev/null +++ b/handwritten/spanner-pg/src/lib/types.ts @@ -0,0 +1,103 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { + decodeValue, + getSpannerTypeCodeFromOid, + registerCustomParserHook, +} from './codec.js'; + +export type TypeParser = (value: string) => any; + +/** + * TypeParserRegistry implements standard node-postgres (pg.types) type parser + * registration and retrieval by PostgreSQL Object Identifier (OID), unifying + * default decoding logic with decodeValue. + */ +class TypeParserRegistry { + private customParsers = new Map(); + + /** + * Returns whether a custom type parser has been registered for this OID. + */ + hasCustomParser(oid: number | string): boolean { + return this.customParsers.has(`${oid}`); + } + + /** + * Retrieves the active type parser for a given PostgreSQL OID and optional format. + * If no custom parser is registered, reuses core Spanner decodeValue logic. + */ + getTypeParser(oid: number | string, format?: string): TypeParser { + const key = `${oid}`; + const custom = this.customParsers.get(key); + if (custom) { + return custom; + } + + const oidNum = typeof oid === 'number' ? oid : parseInt(oid, 10); + const typeCode = getSpannerTypeCodeFromOid(oidNum); + + return (val: string) => decodeValue(val, {code: typeCode}, false); + } + + /** + * Registers a custom type parser function for a given PostgreSQL OID. + * Supports both setTypeParser(oid, parserFn) and setTypeParser(oid, format, parserFn). + */ + setTypeParser( + oid: number | string, + formatOrParser: string | TypeParser, + parseFn?: TypeParser + ): void { + let fn: TypeParser | undefined; + if (typeof formatOrParser === 'function') { + fn = formatOrParser; + } else if (typeof parseFn === 'function') { + fn = parseFn; + } + + if (fn) { + this.customParsers.set(`${oid}`, fn); + } + } + + /** + * Resets all custom type parsers to defaults. + */ + reset(): void { + this.customParsers.clear(); + } +} + +const registry = new TypeParserRegistry(); + +registerCustomParserHook((oid: number) => { + return registry.hasCustomParser(oid) ? registry.getTypeParser(oid) : null; +}); + +export function hasCustomParser(oid: number | string): boolean { + return registry.hasCustomParser(oid); +} + +export const types = { + getTypeParser: (oid: number | string, format?: string) => + registry.getTypeParser(oid, format), + setTypeParser: ( + oid: number | string, + formatOrParser: string | TypeParser, + parseFn?: TypeParser + ) => registry.setTypeParser(oid, formatOrParser, parseFn), + reset: () => registry.reset(), +}; diff --git a/handwritten/spanner-pg/test/client_test.ts b/handwritten/spanner-pg/test/client_test.ts new file mode 100644 index 000000000000..487c6c48f8de --- /dev/null +++ b/handwritten/spanner-pg/test/client_test.ts @@ -0,0 +1,107 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import * as assert from 'assert'; +import * as sinon from 'sinon'; +import {Pool as SpannerPool, Connection} from 'spannerlib-node'; +import {Client} from '../src/index.js'; + +describe('Client Lifecycle', () => { + let createPoolStub: sinon.SinonStub; + let fakePool: sinon.SinonStubbedInstance; + let fakeConnection: sinon.SinonStubbedInstance; + + beforeEach(() => { + fakeConnection = sinon.createStubInstance(Connection); + fakeConnection.close.resolves(); + + fakePool = sinon.createStubInstance(SpannerPool); + fakePool.createConnection.resolves(fakeConnection as unknown as Connection); + fakePool.close.resolves(); + + createPoolStub = sinon + .stub(SpannerPool, 'create') + .resolves(fakePool as unknown as SpannerPool); + }); + + afterEach(() => { + sinon.restore(); + }); + + it('should connect successfully and initialize connection/pool', async () => { + const client = new Client({ + project: 'test-project', + instance: 'test-instance', + database: 'test-db', + }); + + let connectEmitted = false; + client.on('connect', () => { + connectEmitted = true; + }); + + await client.connect(); + + assert.strictEqual(connectEmitted, true); + assert.strictEqual(createPoolStub.calledOnce, true); + assert.strictEqual( + createPoolStub.firstCall.args[0], + 'projects/test-project/instances/test-instance/databases/test-db', + ); + assert.strictEqual(fakePool.createConnection.calledOnce, true); + + await client.end(); + assert.strictEqual(fakeConnection.close.calledOnce, true); + assert.strictEqual(fakePool.close.calledOnce, true); + }); + + it('should support callback syntax for connect and end', done => { + const client = new Client({ + project: 'test-project', + instance: 'test-instance', + database: 'test-db', + }); + + void client.connect(async (err?: any) => { + assert.ifError(err); + assert.strictEqual(createPoolStub.calledOnce, true); + + void client.end((endErr?: any) => { + assert.ifError(endErr); + assert.strictEqual(fakeConnection.close.calledOnce, true); + assert.strictEqual(fakePool.close.calledOnce, true); + done(); + }); + }); + }); + + it('should emit error when connect fails', async () => { + createPoolStub.rejects(new Error('Connection failed')); + + const client = new Client({ + project: 'test-project', + instance: 'test-instance', + database: 'test-db', + }); + + let errorEmitted: Error | null = null; + client.on('error', (err: any) => { + errorEmitted = err; + }); + + await assert.rejects(client.connect(), /Connection failed/); + assert.ok(errorEmitted); + assert.strictEqual((errorEmitted as any).message, 'Connection failed'); + }); +}); diff --git a/handwritten/spanner-pg/test/codec_test.ts b/handwritten/spanner-pg/test/codec_test.ts new file mode 100644 index 000000000000..eb13e00ef539 --- /dev/null +++ b/handwritten/spanner-pg/test/codec_test.ts @@ -0,0 +1,216 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import * as assert from 'assert'; +import * as pkg from '@google-cloud/spanner/build/protos/protos.js'; +import { + encodeValue, + decodeValue, + PgOid, + getSpannerType, + getPgOid, + parsePgArrayLiteral, +} from '../src/lib/codec.js'; + +const google = pkg.google || (pkg as any).default?.google; +const TypeCode = google.spanner.v1.TypeCode; + +describe('Codec Type Transformations', () => { + describe('encodeValue', () => { + it('should encode null / undefined', () => { + const res = encodeValue(null); + assert.deepStrictEqual(res.valueProto, {nullValue: 0}); + assert.strictEqual(res.typeProto.code, TypeCode.STRING); + }); + + it('should encode string', () => { + const res = encodeValue('hello'); + assert.deepStrictEqual(res.valueProto, {stringValue: 'hello'}); + assert.strictEqual(res.typeProto.code, TypeCode.STRING); + }); + + it('should encode boolean', () => { + const res = encodeValue(true); + assert.deepStrictEqual(res.valueProto, {boolValue: true}); + assert.strictEqual(res.typeProto.code, TypeCode.BOOL); + }); + + it('should encode integer as INT64 string', () => { + const res = encodeValue(42); + assert.deepStrictEqual(res.valueProto, {stringValue: '42'}); + assert.strictEqual(res.typeProto.code, TypeCode.INT64); + }); + + it('should encode float as FLOAT64 number', () => { + const res = encodeValue(3.14); + assert.deepStrictEqual(res.valueProto, {numberValue: 3.14}); + assert.strictEqual(res.typeProto.code, TypeCode.FLOAT64); + }); + + it('should encode Date as TIMESTAMP string', () => { + const date = new Date('2026-06-17T08:00:00Z'); + const res = encodeValue(date); + assert.deepStrictEqual(res.valueProto, {stringValue: date.toISOString()}); + assert.strictEqual(res.typeProto.code, TypeCode.TIMESTAMP); + }); + + it('should encode Buffer as BYTES base64 string', () => { + const buf = Buffer.from('hello'); + const res = encodeValue(buf); + assert.deepStrictEqual(res.valueProto, { + stringValue: buf.toString('base64'), + }); + assert.strictEqual(res.typeProto.code, TypeCode.BYTES); + }); + + it('should encode array of strings', () => { + const arr = ['a', 'b']; + const res = encodeValue(arr); + assert.deepStrictEqual(res.valueProto, { + listValue: { + values: [{stringValue: 'a'}, {stringValue: 'b'}], + }, + }); + assert.strictEqual(res.typeProto.code, TypeCode.ARRAY); + assert.strictEqual(res.typeProto.arrayElementType.code, TypeCode.STRING); + }); + + it('should encode generic object as JSON', () => { + const obj = {foo: 'bar'}; + const res = encodeValue(obj); + assert.deepStrictEqual(res.valueProto, { + stringValue: JSON.stringify(obj), + }); + assert.strictEqual(res.typeProto.code, TypeCode.JSON); + }); + }); + + describe('decodeValue', () => { + it('should decode null', () => { + const decoded = decodeValue( + {nullValue: 0}, + {code: TypeCode.TYPE_CODE_UNSPECIFIED}, + ); + assert.strictEqual(decoded, null); + }); + + it('should decode bool', () => { + const decoded = decodeValue({boolValue: true}, {code: TypeCode.BOOL}); + assert.strictEqual(decoded, true); + }); + + it('should decode INT64 to string', () => { + const decoded = decodeValue( + {stringValue: '9007199254740991'}, + {code: TypeCode.INT64}, + ); + assert.strictEqual(decoded, '9007199254740991'); + }); + + it('should decode FLOAT64 to number', () => { + const decoded = decodeValue( + {numberValue: 3.14}, + {code: TypeCode.FLOAT64}, + ); + assert.strictEqual(decoded, 3.14); + }); + + it('should decode TIMESTAMP to Date', () => { + const dateStr = '2026-06-17T08:00:00.000Z'; + const decoded = decodeValue( + {stringValue: dateStr}, + {code: TypeCode.TIMESTAMP}, + ); + assert.ok(decoded instanceof Date); + assert.strictEqual(decoded.toISOString(), dateStr); + }); + + it('should decode BYTES to Buffer', () => { + const base64Str = Buffer.from('hello').toString('base64'); + const decoded = decodeValue( + {stringValue: base64Str}, + {code: TypeCode.BYTES}, + ); + assert.ok(Buffer.isBuffer(decoded)); + assert.strictEqual(decoded.toString(), 'hello'); + }); + + it('should decode array', () => { + const listVal = { + listValue: { + values: [{stringValue: 'a'}, {stringValue: 'b'}], + }, + }; + const type = { + code: TypeCode.ARRAY, + arrayElementType: {code: TypeCode.STRING}, + }; + const decoded = decodeValue(listVal, type); + assert.deepStrictEqual(decoded, ['a', 'b']); + }); + }); + + describe('TypeConversion & PostgreSQL Array Literals', () => { + it('should map PostgreSQL OID to Spanner TypeCode', () => { + assert.deepStrictEqual(getSpannerType(PgOid.INT4), { + code: TypeCode.INT64, + }); + assert.deepStrictEqual(getSpannerType(PgOid.TEXT_ARRAY), { + code: TypeCode.ARRAY, + arrayElementType: {code: TypeCode.STRING}, + }); + assert.deepStrictEqual(getSpannerType(PgOid.INT8_ARRAY), { + code: TypeCode.ARRAY, + arrayElementType: {code: TypeCode.INT64}, + }); + }); + + it('should map Spanner TypeCode to PostgreSQL OID', () => { + assert.strictEqual(getPgOid({code: TypeCode.STRING}), PgOid.TEXT); + assert.strictEqual(getPgOid({code: TypeCode.INT64}), PgOid.INT8); + assert.strictEqual( + getPgOid({ + code: TypeCode.ARRAY, + arrayElementType: {code: TypeCode.STRING}, + }), + PgOid.TEXT_ARRAY, + ); + assert.strictEqual( + getPgOid({ + code: TypeCode.ARRAY, + arrayElementType: {code: TypeCode.INT64}, + }), + PgOid.INT8_ARRAY, + ); + }); + + it('should parse PostgreSQL array string literals', () => { + assert.deepStrictEqual(parsePgArrayLiteral('{1, 2, 3}'), [1, 2, 3]); + assert.deepStrictEqual( + parsePgArrayLiteral('{"aaron", "brian", "a b c"}'), + ['aaron', 'brian', 'a b c'], + ); + }); + + it('should automatically encode PostgreSQL array string literal into Spanner ListValue', () => { + const encoded = encodeValue('{1, 2, 3}', PgOid.INT8_ARRAY); + assert.strictEqual(encoded.typeProto.code, TypeCode.ARRAY); + assert.deepStrictEqual(encoded.valueProto, { + listValue: { + values: [{stringValue: '1'}, {stringValue: '2'}, {stringValue: '3'}], + }, + }); + }); + }); +}); diff --git a/handwritten/spanner-pg/test/config_test.ts b/handwritten/spanner-pg/test/config_test.ts new file mode 100644 index 000000000000..1b0e6eb63087 --- /dev/null +++ b/handwritten/spanner-pg/test/config_test.ts @@ -0,0 +1,65 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import * as assert from 'assert'; +import {resolveDsn} from '../src/lib/config.js'; + +describe('resolveDsn', () => { + it('should resolve standard Spanner DSN string directly', () => { + const dsn = resolveDsn('projects/p/instances/i/databases/d'); + assert.strictEqual(dsn, 'projects/p/instances/i/databases/d'); + }); + + it('should parse postgres connection URL', () => { + const dsn = resolveDsn( + 'postgresql://user:pass@localhost:5432/projects/p/instances/i/databases/d?param=val', + ); + assert.strictEqual(dsn, 'projects/p/instances/i/databases/d?param=val'); + }); + + it('should construct DSN from config object parts', () => { + const dsn = resolveDsn({ + project: 'p', + instance: 'i', + database: 'd', + }); + assert.strictEqual(dsn, 'projects/p/instances/i/databases/d'); + }); + + it('should construct DSN from database path in config object', () => { + const dsn = resolveDsn({ + database: 'projects/p/instances/i/databases/d', + }); + assert.strictEqual(dsn, 'projects/p/instances/i/databases/d'); + }); + + it('should append host and port as api_endpoint', () => { + const dsn = resolveDsn({ + project: 'p', + instance: 'i', + database: 'd', + host: 'localhost', + port: 9010, + }); + assert.strictEqual( + dsn, + 'projects/p/instances/i/databases/d?api_endpoint=localhost:9010', + ); + }); + + it('should throw error for invalid configurations', () => { + assert.throws(() => resolveDsn({database: 'd'})); + assert.throws(() => resolveDsn('postgresql://localhost/invalid_path')); + }); +}); diff --git a/handwritten/spanner-pg/test/errors_test.ts b/handwritten/spanner-pg/test/errors_test.ts new file mode 100644 index 000000000000..d4f359ab8d71 --- /dev/null +++ b/handwritten/spanner-pg/test/errors_test.ts @@ -0,0 +1,56 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import * as assert from 'assert'; +import {describe, it} from 'mocha'; +import {enrichPgError} from '../src/lib/errors.js'; + +describe('enrichPgError SQLSTATE Mapping', () => { + it('should map undefined table errors to 42P01', () => { + const err = enrichPgError(new Error('relation "foo" does not exist')); + assert.strictEqual(err.code, '42P01'); + assert.strictEqual(err.severity, 'ERROR'); + }); + + it('should map undefined column errors to 42703', () => { + const err = enrichPgError(new Error('column "bar" does not exist')); + assert.strictEqual(err.code, '42703'); + assert.strictEqual(err.severity, 'ERROR'); + }); + + it('should map unique violation errors to 23505', () => { + const err = enrichPgError(new Error('Row [zed] in table zoom already exists')); + assert.strictEqual(err.code, '23505'); + assert.strictEqual(err.severity, 'ERROR'); + }); + + it('should map syntax errors to 42601', () => { + const err = enrichPgError(new Error('syntax error at or near SELECT')); + assert.strictEqual(err.code, '42601'); + assert.strictEqual(err.severity, 'ERROR'); + }); + + it('should preserve existing 5-character SQLSTATE string codes', () => { + const orig = new Error('custom error') as any; + orig.code = '12345'; + const err = enrichPgError(orig); + assert.strictEqual(err.code, '12345'); + }); + + it('should fallback to XX000 for unknown errors', () => { + const err = enrichPgError(new Error('random unknown error message')); + assert.strictEqual(err.code, 'XX000'); + assert.strictEqual(err.severity, 'ERROR'); + }); +}); diff --git a/handwritten/spanner-pg/test/query_combinations_test.ts b/handwritten/spanner-pg/test/query_combinations_test.ts new file mode 100644 index 000000000000..7487eaefe097 --- /dev/null +++ b/handwritten/spanner-pg/test/query_combinations_test.ts @@ -0,0 +1,262 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import * as assert from 'assert'; +import * as sinon from 'sinon'; +import {Pool as SpannerPool, Connection} from 'spannerlib-node'; +import {Client} from '../src/index.js'; + +describe('Query Execution Combinations', () => { + let createPoolStub: sinon.SinonStub; + let fakePool: sinon.SinonStubbedInstance; + let fakeConnection: any; + + beforeEach(() => { + fakeConnection = { + execute: sinon.stub(), + close: sinon.stub().resolves(), + }; + + fakePool = sinon.createStubInstance(SpannerPool); + fakePool.createConnection.resolves(fakeConnection as unknown as Connection); + fakePool.close.resolves(); + + createPoolStub = sinon + .stub(SpannerPool, 'create') + .resolves(fakePool as unknown as SpannerPool); + }); + + afterEach(() => { + sinon.restore(); + }); + + function setupFakeRows( + fakeRowsData: any[], + fields: any[] = [{name: 'id', type: {code: 3}}], + ) { + let index = 0; + const mockRowsIterator = { + metadata: sinon.stub().resolves({ + rowType: {fields}, + }), + next: sinon.stub().callsFake(async () => { + if (index < fakeRowsData.length) { + const row = fakeRowsData[index++]; + return {values: row.map((v: any) => ({stringValue: String(v)}))}; + } + return null; + }), + resultSetStats: sinon.stub().resolves({ + rowCountExact: fakeRowsData.length, + }), + close: sinon.stub().resolves(), + }; + fakeConnection.execute.resolves(mockRowsIterator); + return mockRowsIterator; + } + + it('should support Promise/async/await syntax', async () => { + setupFakeRows([[1], [2]]); + const client = new Client({ + connectionString: + 'postgresql://localhost/projects/test-project/instances/test-instance/databases/test-db', + }); + await client.connect(); + + const result = await client.query('SELECT id FROM users'); + assert.deepStrictEqual(result.rows, [{id: '1'}, {id: '2'}]); + assert.strictEqual(result.rowCount, 2); + assert.strictEqual(result.command, 'SELECT'); + assert.strictEqual(result.fields[0].name, 'id'); + }); + + it('should support Callback syntax', done => { + setupFakeRows([[10], [20]]); + const client = new Client({ + connectionString: + 'postgresql://localhost/projects/test-project/instances/test-instance/databases/test-db', + }); + + void (async () => { + await client.connect(); + void client.query( + 'SELECT id FROM users', + (err: Error | null, result?: any) => { + assert.ifError(err); + assert.ok(result); + assert.deepStrictEqual(result.rows, [{id: '10'}, {id: '20'}]); + assert.strictEqual(result.rowCount, 2); + done(); + }, + ); + })(); + }); + + it('should support Event-Driven Row Streaming', done => { + setupFakeRows([[100], [200]]); + const client = new Client({ + connectionString: + 'postgresql://localhost/projects/test-project/instances/test-instance/databases/test-db', + }); + + void (async () => { + await client.connect(); + const query = client.query('SELECT id FROM users'); + + const rowsReceived: any[] = []; + void query.on('row', row => { + rowsReceived.push(row); + }); + + void query.on('end', result => { + assert.deepStrictEqual(rowsReceived, [{id: '100'}, {id: '200'}]); + assert.deepStrictEqual(result.rows, [{id: '100'}, {id: '200'}]); + assert.strictEqual(result.rowCount, 2); + done(); + }); + + void query.on('error', err => { + done(err); + }); + })(); + }); + + it('should support Parameter Bindings', async () => { + setupFakeRows([[1]]); + const client = new Client({ + connectionString: + 'postgresql://localhost/projects/test-project/instances/test-instance/databases/test-db', + }); + await client.connect(); + + await client.query('SELECT id FROM users WHERE id = $1 AND name = $2', [ + 42, + 'bob', + ]); + + assert.strictEqual(fakeConnection.execute.calledOnce, true); + const execArg = fakeConnection.execute.firstCall.args[0]; + assert.strictEqual( + execArg.sql, + 'SELECT id FROM users WHERE id = $1 AND name = $2', + ); + assert.ok(execArg.params.fields.p1); + assert.ok(execArg.params.fields.p2); + }); + + it('should propagate errors correctly through Promise', async () => { + fakeConnection.execute.rejects(new Error('Execute failed')); + const client = new Client({ + connectionString: + 'postgresql://localhost/projects/test-project/instances/test-instance/databases/test-db', + }); + await client.connect(); + + await assert.rejects(async () => { + await client.query('SELECT id FROM users'); + }, /Execute failed/); + }); + + it('should propagate errors correctly through Callback', done => { + fakeConnection.execute.rejects(new Error('Execute failed')); + const client = new Client({ + connectionString: + 'postgresql://localhost/projects/test-project/instances/test-instance/databases/test-db', + }); + + void (async () => { + await client.connect(); + void client.query('SELECT id FROM users', (err: Error | null) => { + assert.ok(err); + assert.match(err!.message, /Execute failed/); + done(); + }); + })(); + }); + + it('should emit drain event when the query queue becomes empty', done => { + setupFakeRows([[1]]); + const client = new Client({ + connectionString: + 'postgresql://localhost/projects/test-project/instances/test-instance/databases/test-db', + }); + + void (async () => { + await client.connect(); + client.on('drain', () => { + done(); + }); + void client.query('SELECT id FROM users'); + })(); + }); + + it('should reject query attempts after end() is called', done => { + const client = new Client({ + connectionString: + 'postgresql://localhost/projects/test-project/instances/test-instance/databases/test-db', + }); + void (async () => { + await client.connect(); + void client.end(); + + void client.query('SELECT 1', (err: Error | null) => { + assert.ok(err); + assert.strictEqual( + err!.message, + 'Client was closed and is not queryable', + ); + done(); + }); + })(); + }); + + it('should drain query queue before closing connection in end()', done => { + let queryResolved = false; + let poolClosed = false; + + fakeConnection.execute.callsFake(async () => { + await new Promise(r => setTimeout(r, 10)); + queryResolved = true; + return { + metadata: async () => ({rowType: {fields: []}}), + next: async () => null, + resultSetStats: async () => ({rowCountExact: 0}), + close: async () => {}, + }; + }); + + fakePool.close.callsFake(async () => { + assert.strictEqual( + queryResolved, + true, + 'Query should resolve before pool is closed', + ); + poolClosed = true; + }); + + const client = new Client({ + connectionString: + 'postgresql://localhost/projects/test-project/instances/test-instance/databases/test-db', + }); + + void (async () => { + await client.connect(); + void client.query('SELECT 1'); + void client.end(() => { + assert.strictEqual(poolClosed, true); + done(); + }); + })(); + }); +}); diff --git a/handwritten/spanner-pg/test/types_test.ts b/handwritten/spanner-pg/test/types_test.ts new file mode 100644 index 000000000000..ef2fa7eb8027 --- /dev/null +++ b/handwritten/spanner-pg/test/types_test.ts @@ -0,0 +1,54 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import * as assert from 'assert'; +import {describe, it, beforeEach} from 'mocha'; +import {types} from '../src/index.js'; +import {PgOid} from '../src/lib/codec.js'; + +describe('types (getTypeParser & setTypeParser)', () => { + beforeEach(() => { + (types as any).reset(); + }); + + it('should return default parser for JSON OID (114)', () => { + const parser = types.getTypeParser(PgOid.JSON, 'text'); + assert.deepStrictEqual(parser('{"a":1}'), {a: 1}); + }); + + it('should return default parser for BOOL OID (16)', () => { + const parser = types.getTypeParser(PgOid.BOOL); + assert.strictEqual(parser('t'), true); + assert.strictEqual(parser('f'), false); + }); + + it('should allow overriding parser with setTypeParser(oid, fn)', () => { + types.setTypeParser(PgOid.INT8, (val: string) => `big_${val}`); + const parser = types.getTypeParser(PgOid.INT8); + assert.strictEqual(parser('9999'), 'big_9999'); + }); + + it('should allow overriding parser with setTypeParser(oid, format, fn)', () => { + types.setTypeParser(20, 'text', (val: string) => BigInt(val)); + const parser = types.getTypeParser(20, 'text'); + assert.strictEqual(parser('12345'), BigInt(12345)); + }); + + it('should automatically apply custom parser inside decodeValue', async () => { + const {decodeValue} = await import('../src/lib/codec.js'); + types.setTypeParser(PgOid.INT8, (val: string) => `custom_int8_${val}`); + const decoded = decodeValue('42', {code: 'INT64'}); + assert.strictEqual(decoded, 'custom_int8_42'); + }); +}); diff --git a/handwritten/spanner-pg/tsconfig.cjs.json b/handwritten/spanner-pg/tsconfig.cjs.json new file mode 100644 index 000000000000..d1b66608ef62 --- /dev/null +++ b/handwritten/spanner-pg/tsconfig.cjs.json @@ -0,0 +1,27 @@ +{ + "extends": "./node_modules/gts/tsconfig-google.json", + "compilerOptions": { + "rootDir": ".", + "outDir": "build/cjs", + "resolveJsonModule": true, + "skipLibCheck": true, + "esModuleInterop": true, + "types": [ + "node", + "mocha" + ], + "lib": [ + "es2023" + ] + }, + "include": [ + "src/*.ts", + "src/**/*.ts", + "test/*.ts", + "test/**/*.ts", + "production-test/*.ts" + ], + "exclude": [ + "test/pg-suite/cloudflare" + ] +} diff --git a/handwritten/spanner-pg/tsconfig.json b/handwritten/spanner-pg/tsconfig.json new file mode 100644 index 000000000000..65e1b9a989dd --- /dev/null +++ b/handwritten/spanner-pg/tsconfig.json @@ -0,0 +1,28 @@ +{ + "extends": "./node_modules/gts/tsconfig-google.json", + "compilerOptions": { + "rootDir": ".", + "outDir": "build/esm", + "module": "nodenext", + "moduleResolution": "nodenext", + "resolveJsonModule": true, + "skipLibCheck": true, + "types": [ + "node", + "mocha" + ], + "lib": [ + "es2023" + ] + }, + "include": [ + "src/*.ts", + "src/**/*.ts", + "test/*.ts", + "test/**/*.ts", + "production-test/*.ts" + ], + "exclude": [ + "test/pg-suite/cloudflare" + ] +}