diff --git a/handwritten/spanner-driver/README.md b/handwritten/spanner-driver/README.md index 4dec548307c0..b4e04cd50b9d 100644 --- a/handwritten/spanner-driver/README.md +++ b/handwritten/spanner-driver/README.md @@ -1,6 +1,6 @@ # Google Spanner Node.js Driver (`@google-cloud/spanner-driver`) -The `@google-cloud/spanner-driver` package provides a high-performance, `node-postgres` (`pg`) compatible client and connection pool interface for Google Spanner. It bridges Node.js applications directly to Spanner using a native Go CGO engine, delivering full PostgreSQL dialect support. +The `@google-cloud/spanner-driver` package provides a high-performance, `node-postgres` (`pg`) compatible client and connection pool interface for Cloud Spanner, delivering full PostgreSQL dialect compatibility with low latency and seamless ORM integration. [![npm version](https://img.shields.io/npm/v/@google-cloud/spanner-driver.svg)](https://www.npmjs.com/package/@google-cloud/spanner-driver) [![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](LICENSE) @@ -10,10 +10,14 @@ The `@google-cloud/spanner-driver` package provides a high-performance, `node-po ## Key Features - **`node-postgres` Compatibility**: Drop-in compatible `Client` and `Pool` interfaces matching standard PostgreSQL drivers. -- **Connection Pooling**: Full-featured connection pool (`Pool`) with idle eviction, connection recycling (`maxUses`, `maxLifetimeSeconds`), and backpressure wait queues. -- **Dual ESM & CommonJS**: Full support for both `import` (ESM) and `require()` (CommonJS) modules. -- **PostgreSQL Dialect Utilities**: Escaping tools (`escapeIdentifier`, `escapeLiteral`) and SQLSTATE error code enrichment (`DatabaseError`). -- **Flexible Invocation Modes**: Supports Promises (`async`/`await`), Node callbacks, and streaming row event emitters. +- **High Performance**: Optimized, low-overhead communication directly with Cloud Spanner. +- **Transaction Support**: Explicit transaction management (`BEGIN`, `COMMIT`, `ROLLBACK`) with transaction readiness status tracking (`client.txStatus`). +- **Robust Connection Pooling**: Full-featured connection pool (`Pool`) with idle eviction, connection recycling (`maxUses`, `maxLifetimeSeconds`), backpressure wait queues, and async `onConnect` initialization hooks. +- **Custom Type System**: `pg.types`-compatible `TypeOverrides` registry allowing global, per-client, or per-query custom parsers for PostgreSQL OID types. +- **Rich Parameter Serialization**: Automatic encoding of JavaScript primitives, `Date` objects, `Buffer` bytes, JSON, and ORM objects implementing `.toPostgres()`. +- **Flexible Invocation Modes**: Full support for Promises (`async`/`await`), Node callbacks (`(err, res) => ...`), and streaming row event emitters (`.on('row')`, `.on('fields')`). +- **Dual ESM & CommonJS**: Native exports supporting both modern `import` (ESM) and legacy `require()` (CommonJS). +- **PostgreSQL Utilities**: Escaping helpers (`escapeIdentifier`, `escapeLiteral`) and SQLSTATE error code enrichment (`DatabaseError`). --- @@ -39,15 +43,16 @@ const client = new Client({ database: 'my-spanner-database', }); -// Option B: Connection DSN String or postgresql:// URL +// Option B: Connection DSN Resource String // const client = new Client('projects/my-gcp-project/instances/my-spanner-instance/databases/my-spanner-database'); async function main() { + // connect() returns Promise await client.connect(); // Executing queries with positional parameters ($1, $2, etc.) const result = await client.query( - 'SELECT user_id, email FROM users WHERE status = $1', + 'SELECT user_id, email, created_at FROM users WHERE status = $1', ['ACTIVE'] ); @@ -61,6 +66,8 @@ async function main() { main().catch(console.error); ``` +--- + ### 2. Connection Pooling via `Pool` ```typescript @@ -83,7 +90,6 @@ const pool = new Pool({ }, }); -// Pool Lifecycle Events (Fire-and-forget notifications) // Note: 'connect' event listeners do NOT wait for async functions; use onConnect option for async setup. pool.on('connect', client => console.log('New client connected to pool')); pool.on('acquire', client => console.log('Client checked out from pool')); @@ -115,7 +121,7 @@ async function shutdown() { #### Monitoring Pool Metrics -You can inspect real-time connection metrics on the `Pool` instance: +You can inspect real-time connection metrics directly on the `Pool` instance: ```typescript console.log(`Total Connections: ${pool.totalCount}`); // Total clients in pool (active + idle) @@ -123,10 +129,12 @@ console.log(`Idle Connections: ${pool.idleCount}`); // Clients currently ava console.log(`Waiting Requests: ${pool.waitingCount}`); // Queued queries waiting for an available client ``` -### 3. Streaming Rows & Callbacks +--- + +### 3. Streaming Rows & Events ```typescript -import { Client } from '@google-cloud/spanner-driver'; +import { Client, Query } from '@google-cloud/spanner-driver'; const client = new Client({ project: 'my-gcp-project', @@ -134,15 +142,100 @@ const client = new Client({ database: 'my-spanner-database', }); -// Streaming row events +await client.connect(); + +// Stream rows as they arrive from Spanner gRPC stream client.query('SELECT * FROM large_table') + .on('fields', fields => console.log('Column metadata:', fields)) .on('row', row => console.log('Received Row:', row)) - .on('end', result => console.log('Query finished. Total rows:', result.rowCount)) - .on('error', err => console.error('Error:', err)); + .on('end', result => console.log('Query completed. Row count:', result.rowCount)) + .on('error', err => console.error('Query error:', err)); ``` --- +### 4. Custom Type Parsers (`types` & `TypeOverrides`) + +The driver provides a `node-postgres` compatible type system (`pg.types`) for customizing how PostgreSQL OID column types are deserialized: + +#### Global Type Parser Override + +```typescript +import { types, BuiltinOids } from '@google-cloud/spanner-driver'; + +// Example: Parse INT8 / BIGINT as native JavaScript BigInt globally +types.setTypeParser(BuiltinOids.INT8, (val: string) => BigInt(val)); + +// Example: Parse exact NUMERIC into a custom Decimal instance globally +types.setTypeParser(BuiltinOids.NUMERIC, (val: string) => new Decimal(val)); +``` + +#### Scoped Type Overrides (Per-Client or Per-Query) + +```typescript +import { Client, TypeOverrides, BuiltinOids } from '@google-cloud/spanner-driver'; + +// 1. Scoped to a specific Client instance +const clientTypes = new TypeOverrides(); +clientTypes.setTypeParser(BuiltinOids.INT8, (val) => BigInt(val)); + +const client = new Client({ + project: 'my-project', + instance: 'my-instance', + database: 'my-db', + types: clientTypes, // Applies to all queries executed on this client +}); + +// 2. Scoped to a single Query execution +const queryTypes = new TypeOverrides(); +queryTypes.setTypeParser(BuiltinOids.INT8, (val) => Number(val)); + +const res = await client.query({ + text: 'SELECT id, count FROM metrics WHERE id = $1', + values: [1], + types: queryTypes, // Only this query uses queryTypes +}); +``` + +#### Supported Data Types & Default Mappings + +| Spanner / PG Type | OID Code | Default JavaScript Output | Custom Parser Example | +| :--- | :---: | :--- | :--- | +| `BOOL` | `16` | `boolean` (`true` / `false`) | — | +| `BYTEA` / `BYTES` | `17` | Node.js `Buffer` | Raw hex string | +| `INT8` / `BIGINT` | `20` | `string` *(safe against $> 2^{53}-1$ overflow)* | `BigInt(val)` / `Number(val)` | +| `INT2` / `SMALLINT` | `21` | `number` | — | +| `INT4` / `INTEGER` | `23` | `number` | — | +| `TEXT` / `VARCHAR` | `25` / `1043` | `string` | — | +| `JSON` / `JSONB` | `114` / `3802` | `object` / `any` (`JSON.parse`) | Raw string | +| `FLOAT4` / `REAL` | `700` | `number` | — | +| `FLOAT8` / `FLOAT64` | `701` | `number` | — | +| `DATE` | `1082` | `string` (`YYYY-MM-DD`) | `new Date(val)` | +| `TIMESTAMP` / `TIMESTAMPTZ` | `1114` / `1184` | JavaScript `Date` (UTC) | ISO string | +| `NUMERIC` | `1700` | `string` *(preserves exact decimal precision)* | `new Decimal(val)` | +| `UUID` | `2950` | `string` | — | +| `ARRAY` | `1007`, `1016`, etc. | `T[]` *(nested arrays with element parser)* | Custom element parser | + +--- + +## Spanner PostgreSQL Dialect Considerations + +When working with Cloud Spanner's PostgreSQL dialect, keep the following behavioral characteristics in mind: + +1. **`TIMESTAMPTZ` (1184) vs. `TIMESTAMP WITHOUT TIME ZONE` (1114)**: + - Cloud Spanner requires timezone-aware timestamps (`TIMESTAMPTZ`) for table column schemas. Creating a table column with `TIMESTAMP WITHOUT TIME ZONE` will be rejected by Spanner. + - The driver retains OID `1114` in `BuiltinOids` for backward compatibility with existing ORMs. +2. **Timestamp Literals & Formatting**: + - Cloud Spanner requires ISO-8601 formatted timestamps (e.g. `'2026-08-11T12:00:00Z'`). + - The driver serializes JavaScript `Date` parameter values into ISO-8601 UTC strings. Invalid `Date` objects (`new Date('invalid')`) are safely serialized to `null`. +3. **1D Schema Arrays vs. Multidimensional Expressions**: + - Cloud Spanner table schemas (`DDL`) permit only **1-dimensional arrays** (e.g. `VARCHAR[]`, `BIGINT[]`). + - SQL query projections and expressions can generate multidimensional arrays (e.g. `SELECT ARRAY[ARRAY[1, 2], ARRAY[3, 4]]`). The driver's array parser recursively decodes nested arrays into multidimensional JavaScript arrays (e.g. `[[1, 2], [3, 4]]`). +4. **64-bit Integer Precision (`INT8` / `BIGINT`)**: + - By default, `INT8` columns are returned as strings to prevent precision loss beyond JavaScript's 53-bit `Number.MAX_SAFE_INTEGER` ($9,007,199,254,740,991$). Use `types.setTypeParser(BuiltinOids.INT8, BigInt)` or `Number` based on your application's requirements. + +--- + ## Configuration Reference ### `PoolConfig` @@ -154,9 +247,10 @@ client.query('SELECT * FROM large_table') | `project` | `string` | `process.env.GOOGLE_CLOUD_PROJECT` | GCP Project ID. | | `instance` | `string` | — | Cloud Spanner Instance ID. | | `database` | `string` | — | Cloud Spanner Database ID. | -| `connectionString` | `string` | — | Full Spanner resource path or `postgresql://` DSN URL. | +| `connectionString` | `string` | — | Full Spanner resource path (`projects/p/instances/i/databases/d`). | | `host` | `string` | — | Optional custom endpoint or emulator host. | | `port` | `number` | — | Optional custom endpoint port. | +| `types` | `ITypeOverrides` | `types` (global) | Custom type parser registry instance (`new TypeOverrides()`). | | `max` | `number` | `10` | Maximum number of active and idle connections in the pool. | | `min` | `number` | `0` | Minimum number of idle connections to retain without evicting. | | `idleTimeoutMillis` | `number` | `10000` (10s) | Time a connection can remain idle before being closed (set `0` to disable). | @@ -174,11 +268,18 @@ client.query('SELECT * FROM large_table') | :--- | :--- | :--- | | `Client` | Class | Single database connection client (`connect()`, `query()`, `release()`, `end()`). | | `Pool` | Class | Connection pool manager (`connect()`, `query()`, `end()`, getters: `totalCount`, `idleCount`, `waitingCount`). | +| `Query` | Class | Thenable query class supporting positional parameters, callbacks, rowMode, and event emitters. | +| `types` | Object | Global default `TypeOverrides` registry matching `pg.types` (`getTypeParser()`, `setTypeParser()`, `builtins`). | +| `TypeOverrides` | Class | Instantiable scoped type registry for client/query parser overrides. | +| `BuiltinOids` | Enum | Standard PostgreSQL catalog Object Identifier (OID) constants. | | `DatabaseError` | Class | Enriched database error containing PostgreSQL SQLSTATE `.code` and `.severity`. | -| `ClientConfig` | Interface | Client connection configuration options (`project`, `instance`, `database`, `connectionString`). | +| `ClientConfig` | Interface | Client connection configuration options (`project`, `instance`, `database`, `connectionString`, `types`). | | `PoolConfig` | Interface | Pool management configuration options extending `ClientConfig`. | | `QueryResult` | Interface | Result set container (`rows`, `fields`, `rowCount`, `command`). | -| `QueryConfig` | Interface | Query options object (`text`, `values`, `rowMode`). | +| `QueryConfig` | Interface | Query options object (`text`, `values`, `rowMode`, `types`). | +| `FieldDef` | Interface | Column metadata definition (`name`, `dataTypeID`). | +| `ITypeOverrides` | Interface | Generic dialect-agnostic type codec interface. | +| `TypeParser` | Type | Function type signature for parsing raw wire string into JavaScript value. | | `escapeIdentifier` | Function | Escapes PostgreSQL identifiers with double quotes (`"my_table"`). | | `escapeLiteral` | Function | Escapes PostgreSQL string literals with single quotes (`'val'`). | diff --git a/handwritten/spanner-driver/package.json b/handwritten/spanner-driver/package.json index 00539eaa4c11..79349c08ccd5 100644 --- a/handwritten/spanner-driver/package.json +++ b/handwritten/spanner-driver/package.json @@ -41,6 +41,7 @@ "test:esm": "c8 mocha build/esm/test/unit/**/*.js", "test:cjs": "c8 mocha build/cjs/test/unit/**/*.js", "test": "npm run test:esm && npm run test:cjs", + "system-test": "mocha build/esm/system-test/**/*.js --timeout 60000", "test:pg-suite": "node test/pg-suite/run_pg_suite.cjs" }, "dependencies": { @@ -58,5 +59,8 @@ }, "engines": { "node": ">=18" + }, + "optionalDependencies": { + "spannerlib-node": "file:../../../go-sql-spanner/spannerlib/wrappers/spannerlib-node/spannerlib-node-0.1.0.tgz" } } diff --git a/handwritten/spanner-driver/src/index.ts b/handwritten/spanner-driver/src/index.ts index f91bc1e8a887..0dc2a0a01a6c 100644 --- a/handwritten/spanner-driver/src/index.ts +++ b/handwritten/spanner-driver/src/index.ts @@ -15,21 +15,33 @@ import {Client} from './lib/client.js'; import {ClientConfig, PoolConfig} from './lib/config.js'; import {DatabaseError} from './lib/errors.js'; +import {BuiltinOids, TypeOverrides, types} from './lib/pg/types.js'; import {escapeIdentifier, escapeLiteral} from './lib/pg/utilities.js'; import {Pool} from './lib/pool.js'; import {Query} from './lib/query.js'; -import {FieldDef, QueryConfig, QueryResult} from './lib/types.js'; +import { + FieldDef, + ITypeOverrides, + QueryConfig, + QueryResult, + TypeParser, +} from './lib/types.js'; export { + BuiltinOids, Client, ClientConfig, DatabaseError, FieldDef, + ITypeOverrides, Pool, PoolConfig, Query, QueryConfig, QueryResult, + TypeOverrides, + TypeParser, escapeIdentifier, escapeLiteral, + types, }; diff --git a/handwritten/spanner-driver/src/lib/client.ts b/handwritten/spanner-driver/src/lib/client.ts index f2133de62ea9..a52e053b198c 100644 --- a/handwritten/spanner-driver/src/lib/client.ts +++ b/handwritten/spanner-driver/src/lib/client.ts @@ -17,8 +17,10 @@ import {ClientConfig, resolveDsn} from './config.js'; import {DEFAULT_DIALECT, Dialect} from './constants.js'; import {enrichError} from './errors.js'; import {Query, QueryCallback} from './query.js'; -import {QueryConfig, QueryResult} from './types.js'; +import {Codec} from './codec.js'; +import {ITypeOverrides, QueryConfig, QueryResult} from './types.js'; import {dispatchQueryError, normalizeQueryArgs} from './utilities.js'; +import {Connection, Pool} from './native.js'; /** * Task entry stored in single-connection query execution queue. @@ -36,6 +38,12 @@ interface QueryTask { * execution, transaction state tracking (`txStatus`), and dialect-aware error enrichment. */ export class Client extends EventEmitter { + /** Native Spanner connection pool handle from spannerlib-node. */ + private nativePool?: Pool; + + /** Native Spanner connection handle from spannerlib-node. */ + private nativeConnection?: Connection; + /** Resolved ClientConfig object passed on instantiation. */ readonly config: ClientConfig; @@ -45,6 +53,9 @@ export class Client extends EventEmitter { /** Active SQL dialect (defaults to `'pg'`). */ readonly dialect: Dialect = DEFAULT_DIALECT; + /** Type parser override registry configured on this client. */ + readonly types?: ITypeOverrides; + /** Boolean indicating whether connection has been established. */ public isConnected = false; @@ -80,6 +91,7 @@ export class Client extends EventEmitter { this.config = typeof config === 'string' ? {connectionString: config} : config || {}; this.dsn = this.config.connectionString || resolveDsn(this.config); + this.types = this.config.types; } /** @@ -88,15 +100,17 @@ export class Client extends EventEmitter { * * @returns Promise resolving when connection is established, or void if callback is passed. */ - async connect(): Promise; - connect(callback: (err: Error | null) => void): void; - connect(callback?: (err: Error | null) => void): Promise | void { + connect(): Promise; + connect(callback: (err: Error | null, client?: this) => void): void; + connect( + callback?: (err: Error | null, client?: this) => void, + ): Promise | void { if (this.isConnected) { if (callback) { - process.nextTick(() => callback(null)); + process.nextTick(() => callback(null, this)); return; } - return Promise.resolve(); + return Promise.resolve(this); } if (!this.connectPromise) { @@ -111,28 +125,50 @@ export class Client extends EventEmitter { if (callback) { this.connectPromise - .then(() => callback(null)) + .then(() => callback(null, this)) .catch(err => callback(err)); return; } - return this.connectPromise; + return this.connectPromise.then(() => this); } private async _doConnect(): Promise { + if (this.isEnded) { + throw enrichError( + new Error('Cannot connect: Client was already closed'), + this.dialect, + ); + } if (this.isConnected) { return; } - if (this.isEnded) { - throw new Error('Client was closed'); - } try { if (!this.dsn) { throw new Error( 'Invalid Spanner connection configuration: project, instance, and database must be provided.', ); } - // TODO(PR 4 - Native CGO Bridge): Instantiate native CGO Spanner connection handle via spannerlib-node - if (!this.isEnded) { + const pool = await Pool.create(this.dsn); + this.nativePool = pool; + try { + this.nativeConnection = await pool.createConnection(); + this.txStatus = this.nativeConnection.transactionState || 'I'; + } catch (err) { + await pool.close().catch(() => {}); + this.nativePool = undefined; + throw err; + } + + if (this.isEnded) { + if (this.nativeConnection) { + await this.nativeConnection.close().catch(() => {}); + this.nativeConnection = undefined; + } + if (this.nativePool) { + await this.nativePool.close().catch(() => {}); + this.nativePool = undefined; + } + } else { this.isConnected = true; } } catch (err) { @@ -193,6 +229,18 @@ export class Client extends EventEmitter { return query; } + if (this.isEnded) { + const err = enrichError( + new Error('Client was closed and is not queryable'), + this.dialect, + ); + dispatchQueryError(err, query, actualCallback); + const executionPromise = Promise.reject>(err); + executionPromise.catch(() => {}); + query.setPromise(executionPromise); + return query; + } + let resolveTask!: (val: QueryResult) => void; let rejectTask!: (err: unknown) => void; const executionPromise = new Promise>((res, rej) => { @@ -207,28 +255,122 @@ export class Client extends EventEmitter { run: async () => { try { if (this.isEnded) { - throw new Error('Client was closed'); + throw new Error('Client was closed and is not queryable'); } if (!this.isConnected) { await this.connect(); } - // TODO(PR 4 - Native CGO Bridge): Execute query through native CGO bridge (spannerlib-node). - // Both `command` and `txStatus` ('I', 'T', or 'E') will be returned by the native backend driver. - const result: QueryResult = { - rows: [], - fields: [], - rowCount: 0, - command: 'SELECT', - }; + const resultSets: QueryResult[] = []; + + if (this.nativeConnection) { + // Encode params + let executeRequest: Parameters[0] = sqlText; + if (query.values && query.values.length > 0) { + const {fields} = Codec.encodeParams(query.values, this.dialect); + executeRequest = { + sql: sqlText, + params: {fields}, + }; + } + const nativeRows = + await this.nativeConnection.execute(executeRequest); + try { + this.txStatus = this.nativeConnection.transactionState || 'I'; + + let hasMoreResultSets = true; + while (hasMoreResultSets) { + const metadata = await nativeRows.metadata(); + const fields = Codec.mapMetadataToFieldDefs( + metadata, + this.dialect, + ); + if (fields.length > 0) { + query.emit('fields', fields); + } + + const parsers = Codec.getTypeParsers( + fields, + query.types || this.types, + this.dialect, + ); + + const currentRows: R[] = []; + const currentResult: QueryResult = { + rows: currentRows, + fields, + rowCount: 0, + // TODO(PR - Command Resolution): Parse or receive SQL command tag from backend + command: 'SELECT', + }; + + let listValue; + while ((listValue = await nativeRows.next()) !== null) { + const rawRow = Codec.extractRawRow(listValue); + const decoded = Codec.decodeRow( + rawRow, + fields, + parsers, + query.rowMode, + ); + currentRows.push(decoded); + currentResult.rowCount = currentRows.length; + query.emit('row', decoded, currentResult); + } + + const stats = await nativeRows.resultSetStats(); + if ( + stats && + stats.rowCountExact !== undefined && + stats.rowCountExact !== null + ) { + currentResult.rowCount = + typeof stats.rowCountExact === 'number' + ? stats.rowCountExact + : parseInt(String(stats.rowCountExact), 10); + } else { + const updateCount = await nativeRows.updateCount(); + currentResult.rowCount = + updateCount >= 0 ? updateCount : currentRows.length; + } + + // TODO(PR - Command Resolution): Parse or receive SQL command tag from backend + currentResult.command = 'SELECT'; + resultSets.push(currentResult); + + if (typeof nativeRows.nextResultSet === 'function') { + hasMoreResultSets = Boolean(await nativeRows.nextResultSet()); + } else { + hasMoreResultSets = false; + } + } + } finally { + await nativeRows.close(); + } + } + + const finalResult: QueryResult | QueryResult[] = + resultSets.length > 1 + ? resultSets + : resultSets[0] || { + rows: [], + fields: [], + rowCount: 0, + command: 'SELECT', + }; - query.emit('end', result); + query.emit('end', finalResult); if (actualCallback) { - process.nextTick(() => actualCallback!(null, result)); + process.nextTick(() => + actualCallback!(null, finalResult as QueryResult), + ); } - resolveTask(result); - return result; + resolveTask(finalResult as QueryResult); + return finalResult as QueryResult; } catch (err: unknown) { + if (this.nativeConnection) { + this.txStatus = this.nativeConnection.transactionState || 'I'; + } const enriched = enrichError(err, this.dialect); dispatchQueryError(enriched, query, actualCallback); rejectTask(enriched); @@ -275,8 +417,6 @@ export class Client extends EventEmitter { * the pool's release handler to return the connection back to the idle pool (or pass * it directly to queued queries) instead of closing the underlying connection. * - * TODO(PR 7 - Native CGO Bridge): Native connection handle closure (await this.connection?.close()) will be wired up in PR 7. - * * @param err - Optional error flag/instance or Node callback function. * @returns Promise resolving when connection is released, or void if callback is passed. */ @@ -311,13 +451,43 @@ export class Client extends EventEmitter { } private async _doEnd(): Promise { + if ( + !this.connectPromise && + !this.isConnected && + !this.nativeConnection && + !this.nativePool && + this.queryQueue.length === 0 + ) { + return; + } this.isEnded = true; this.isConnected = false; - // TODO(PR 7 - Native CGO Bridge): Close native CGO Spanner connection handle (await this.connection?.close()) + this.txStatus = 'I'; + + try { + if (this.nativeConnection && !this.nativeConnection.closed) { + await this.nativeConnection.close(); + } + } catch { + // Ignore native closure errors during teardown + } finally { + this.nativeConnection = undefined; + } + + try { + if (this.nativePool && !this.nativePool.closed) { + await this.nativePool.close(); + } + } catch { + // Ignore native closure errors during teardown + } finally { + this.nativePool = undefined; + } + // Cancel pending queries in queue to prevent execution after client close const pendingTasks = this.queryQueue; this.queryQueue = []; - const closeError = new Error('Client was closed'); + const closeError = new Error('Client was closed and is not queryable'); for (const task of pendingTasks) { if (task.cancel) { task.cancel(closeError); diff --git a/handwritten/spanner-driver/src/lib/codec.ts b/handwritten/spanner-driver/src/lib/codec.ts new file mode 100644 index 000000000000..f5c375129324 --- /dev/null +++ b/handwritten/spanner-driver/src/lib/codec.ts @@ -0,0 +1,324 @@ +// 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 {Dialect} from './constants.js'; +import {BuiltinOids, mapSpannerTypeToPgOid} from './pg/types.js'; +import {preparePgValue} from './pg/utilities.js'; +import { + FieldDef, + getDefaultTypeOverrides, + ITypeOverrides, + TypeParser, +} from './types.js'; +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore +import pkg from '@google-cloud/spanner/build/protos/protos.js'; +import type {google as GoogleProto} from '@google-cloud/spanner/build/protos/protos.js'; + +const {google} = pkg as {google: typeof GoogleProto}; +const TypeCode = google.spanner.v1.TypeCode; + +export interface EncodedParam { + valueProto: GoogleProto.protobuf.IValue; + typeProto: GoogleProto.spanner.v1.IType; +} + +/** + * Encapsulates encoding and decoding logic between JavaScript values and + * Cloud Spanner protobuf wire types. + */ +export class Codec { + /** + * Encodes a JavaScript value to Spanner Protobuf value and type format. + * + * @param val - JavaScript value. + * @returns EncodedParam containing `valueProto` and `typeProto`. + */ + static encodeValue(val: unknown): EncodedParam { + if (val === null || val === undefined) { + return { + valueProto: {nullValue: google.protobuf.NullValue.NULL_VALUE}, + typeProto: { + code: google.spanner.v1.TypeCode.TYPE_CODE_UNSPECIFIED, + }, + }; + } + + if (typeof val === 'boolean') { + return { + valueProto: {boolValue: val}, + typeProto: {code: TypeCode.BOOL}, + }; + } + + if (typeof val === 'number') { + // Cloud Spanner represents INT64 as decimal strings in protobuf to avoid 64-bit IEEE 754 precision loss + if (Number.isInteger(val)) { + return { + valueProto: {stringValue: String(val)}, + typeProto: {code: TypeCode.INT64}, + }; + } + return { + valueProto: {numberValue: val}, + typeProto: {code: TypeCode.FLOAT64}, + }; + } + + if (typeof val === 'bigint') { + return { + valueProto: {stringValue: val.toString()}, + typeProto: {code: TypeCode.INT64}, + }; + } + + if (val instanceof Date) { + if (Number.isNaN(val.getTime())) { + return { + valueProto: {nullValue: google.protobuf.NullValue.NULL_VALUE}, + typeProto: {code: TypeCode.TIMESTAMP}, + }; + } + // Serialized as ISO-8601 UTC string for Spanner TIMESTAMPTZ + return { + valueProto: {stringValue: val.toISOString()}, + typeProto: {code: TypeCode.TIMESTAMP}, + }; + } + + if (Buffer.isBuffer(val) || val instanceof Uint8Array) { + // Binary data is transported as base64-encoded strings over protobuf + return { + valueProto: {stringValue: Buffer.from(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: google.spanner.v1.TypeCode.TYPE_CODE_UNSPECIFIED, + }, + }, + }; + } + const encodedElements = val.map(el => Codec.encodeValue(el)); + const firstNonUnspecified = encodedElements.find( + el => + el.typeProto && el.typeProto.code !== TypeCode.TYPE_CODE_UNSPECIFIED, + ); + const elementTypeProto = firstNonUnspecified?.typeProto || { + code: google.spanner.v1.TypeCode.TYPE_CODE_UNSPECIFIED, + }; + 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.STRING}, + }; + } + + return { + valueProto: {stringValue: String(val)}, + typeProto: {code: TypeCode.STRING}, + }; + } + + /** + * Encodes a list of positional parameter values into protobuf `params.fields` + * compatible with `ExecuteSqlRequest`. + * + * @param values - Positional parameter values. + * @param dialect - Active SQL dialect ('pg' or 'googlesql'). + * @returns Object containing `fields` map. + */ + static encodeParams( + values?: unknown[], + dialect: Dialect = 'pg', + ): { + fields: Record; + } { + const fields: Record = {}; + + if (!values || !Array.isArray(values)) { + return {fields}; + } + + for (let i = 0; i < values.length; i++) { + const rawVal = dialect === 'pg' ? preparePgValue(values[i]) : values[i]; + const encoded = Codec.encodeValue(rawVal); + fields[`p${i + 1}`] = encoded.valueProto; + } + + return {fields}; + } + + /** + * Resolves an array of column type parsers for the given fields metadata. + * + * @param fields - Column metadata descriptors. + * @param typeOverrides - Optional type overrides registry. + * @param dialect - Active SQL dialect ('pg' or 'googlesql'). + * @returns Array of parser functions matching the fields in order. + */ + static getTypeParsers( + fields: FieldDef[], + typeOverrides?: ITypeOverrides, + dialect: Dialect = 'pg', + ): TypeParser[] { + const types = typeOverrides || getDefaultTypeOverrides(dialect); + return fields.map(f => types.getTypeParser(f.dataTypeID)); + } + + /** + * Decodes a raw database result row using pre-resolved parser functions. + * + * @template R - Result row type (object or array). + * @param rawRow - Raw column values array from database driver. + * @param fields - Column metadata descriptors (names and OIDs). + * @param parsers - Pre-resolved parser functions array. + * @param rowMode - Formatting mode ('object' or 'array'). + * @returns Formatted JavaScript row object or array. + */ + static decodeRow>( + rawRow: (unknown | null | undefined)[], + fields: FieldDef[], + parsers: TypeParser[], + rowMode?: 'array' | 'object', + ): R { + if (rowMode === 'array') { + return rawRow.map((val, idx) => { + const parser = parsers[idx]; + return val === null || val === undefined + ? null + : parser + ? parser(val) + : val; + }) as unknown as R; + } + + const rowObj: Record = {}; + for (let i = 0; i < fields.length; i++) { + const field = fields[i]; + const val = rawRow[i]; + const parser = parsers[i]; + rowObj[field.name] = + val === null || val === undefined ? null : parser ? parser(val) : val; + } + return rowObj as unknown as R; + } + + /** + * Maps Spanner `ResultSetMetadata` fields to standard `FieldDef` descriptors + * containing column names and numeric PostgreSQL catalog OIDs. + * + * @param metadata - Spanner `ResultSetMetadata` protobuf object. + * @param dialect - Active SQL dialect ('pg' or 'googlesql'). + * @returns Array of `FieldDef` column descriptors. + */ + static mapMetadataToFieldDefs( + metadata: GoogleProto.spanner.v1.IResultSetMetadata | null | undefined, + dialect: Dialect = 'pg', + ): FieldDef[] { + if (!metadata?.rowType?.fields) { + return []; + } + + return metadata.rowType.fields.map(f => { + const colName = f.name || ''; + let dataTypeID: number | string = BuiltinOids.TEXT; + + if (dialect === 'pg') { + dataTypeID = mapSpannerTypeToPgOid( + f.type?.code, + f.type?.arrayElementType, + f.type?.typeAnnotation, + ); + } else { + dataTypeID = String(f.type?.code ?? 'STRING'); + } + + return { + name: colName, + dataTypeID, + }; + }); + } + + /** + * Extracts raw JavaScript/string wire representation from a Spanner protobuf `Value` message. + * + * @param v - Protobuf `Value` object. + * @returns Extracted string, array, or null value. + */ + private static extractSingleValue( + v: GoogleProto.protobuf.IValue | null | undefined, + ): unknown { + if (!v) { + return null; + } + + if (v.nullValue !== undefined && v.nullValue !== null) { + return null; + } + if (v.stringValue !== undefined && v.stringValue !== null) { + return v.stringValue; + } + if (v.numberValue !== undefined && v.numberValue !== null) { + return String(v.numberValue); + } + if (v.boolValue !== undefined && v.boolValue !== null) { + return v.boolValue ? 't' : 'f'; + } + if (v.structValue) { + return JSON.stringify(v.structValue); + } + if (v.listValue) { + return v.listValue.values + ? v.listValue.values.map(el => Codec.extractSingleValue(el)) + : []; + } + return null; + } + + /** + * Extracts raw string/array wire representation from a Spanner protobuf `ListValue` row. + * + * @param listValue - Protobuf `ListValue` returned by `nativeRows.next()`. + * @returns Array of raw column values. + */ + static extractRawRow( + listValue: GoogleProto.protobuf.IListValue | null | undefined, + ): (unknown | null | undefined)[] { + if (!listValue?.values) { + return []; + } + + return listValue.values.map(v => Codec.extractSingleValue(v)); + } +} diff --git a/handwritten/spanner-driver/src/lib/config.ts b/handwritten/spanner-driver/src/lib/config.ts index ebb246d318d8..842185c21bba 100644 --- a/handwritten/spanner-driver/src/lib/config.ts +++ b/handwritten/spanner-driver/src/lib/config.ts @@ -13,6 +13,7 @@ // limitations under the License. import type {Client} from './client.js'; +import type {ITypeOverrides} from './types.js'; /** * Configuration options for establishing a connection to Google Spanner. @@ -31,7 +32,7 @@ export interface ClientConfig { /** Spanner Database ID. */ database?: string; /** Custom type parsers registry. */ - types?: unknown; + types?: ITypeOverrides; } /** diff --git a/handwritten/spanner-driver/src/lib/native.ts b/handwritten/spanner-driver/src/lib/native.ts new file mode 100644 index 000000000000..4886832f5cfb --- /dev/null +++ b/handwritten/spanner-driver/src/lib/native.ts @@ -0,0 +1,23 @@ +// 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 {Connection, Pool, Rows, SpannerLibError} from 'spannerlib-node'; + +// 1. Target Production Platform & Architecture Package Loading: +// In production, prebuilt native binaries will be distributed as optional dependencies: +// import { createRequire } from 'module'; +// const require = createRequire(import.meta.url); +// export const NativeBridge = require(`@google-cloud/spannerlib-node-${process.platform}-${process.arch}`); + +export {Connection, Pool, Rows, SpannerLibError}; diff --git a/handwritten/spanner-driver/src/lib/pg/types.ts b/handwritten/spanner-driver/src/lib/pg/types.ts new file mode 100644 index 000000000000..1f0639d9bad4 --- /dev/null +++ b/handwritten/spanner-driver/src/lib/pg/types.ts @@ -0,0 +1,456 @@ +// 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 type {ITypeOverrides, TypeParser} from '../types.js'; +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore +import pkg from '@google-cloud/spanner/build/protos/protos.js'; +import type {google as GoogleProto} from '@google-cloud/spanner/build/protos/protos.js'; + +const {google} = pkg as {google: typeof GoogleProto}; +const TypeCode = google.spanner.v1.TypeCode; +const TypeAnnotationCode = google.spanner.v1.TypeAnnotationCode; + +export type {TypeParser}; + +/** + * Standard PostgreSQL Object Identifier (OID) type numbers supported by Cloud Spanner. + * Aligned with the official Cloud Spanner PostgreSQL dialect data types specification: + * https://cloud.google.com/spanner/docs/reference/postgresql/data-types + */ +export enum BuiltinOids { + BOOL = 16, + BYTEA = 17, + INT8 = 20, + TEXT = 25, + JSON = 114, + FLOAT4 = 700, + FLOAT8 = 701, + VARCHAR = 1043, + DATE = 1082, + TIMESTAMP = 1114, + TIMESTAMPTZ = 1184, + INTERVAL = 1186, + NUMERIC = 1700, + UUID = 2950, + JSONB = 3802, +} + +// ----------------------------------------------------------------------------- +// Text Format Parsers (matches pg-types/lib/textParsers.js) +// ----------------------------------------------------------------------------- + +/** + * Parses PostgreSQL boolean text wire strings into boolean. + */ +export function parseBool(val: unknown): boolean { + if (val === null || val === undefined) return false; + if (typeof val === 'boolean') return val; + if (typeof val !== 'string') return Boolean(val); + const trimmed = val.trim().toLowerCase(); + if (!trimmed) return false; + return ( + trimmed === '1' || + trimmed === 'on' || + 'true'.startsWith(trimmed) || + 'yes'.startsWith(trimmed) + ); +} + +/** + * Parses integer text wire strings into number. + */ +export function parseInteger(val: unknown): number { + if (typeof val === 'number') return Math.trunc(val); + if (typeof val !== 'string') return Number(val); + return parseInt(val, 10); +} + +/** + * Parses floating point text wire strings into number. + */ +export function parseFloatVal(val: unknown): number { + if (typeof val === 'number') return val; + if (typeof val !== 'string') return Number(val); + return parseFloat(val); +} + +/** + * Default pass-through string parser (used for TEXT, VARCHAR, UUID, INT8, NUMERIC, DATE). + */ +export function parseString(val: unknown): string { + if (typeof val === 'string') return val; + return String(val); +} + +/** + * Parses JSON and JSONB text wire strings into JavaScript objects. + */ +export function parseJson(val: unknown): unknown { + if (val === null || val === undefined) return null; + if (typeof val === 'object') return val; + if (typeof val !== 'string') return val; + try { + return JSON.parse(val); + } catch { + return val; + } +} + +/** + * Parses Spanner BYTEA / BYTES base64 or hex wire strings into Node.js Buffer. + */ +export function parseBytea(val: unknown): Buffer { + if (Buffer.isBuffer(val)) return val; + if (val instanceof Uint8Array) return Buffer.from(val); + if (typeof val !== 'string') return Buffer.from(String(val)); + const trimmed = val.trim(); + if (trimmed.startsWith('\\x')) { + return Buffer.from(trimmed.slice(2), 'hex'); + } + return Buffer.from(trimmed, 'base64'); +} + +/** + * Parses PostgreSQL timestamp and timestamptz text wire strings into JavaScript Date objects. + */ +export function parseTimestamp(val: unknown): Date { + if (val instanceof Date) return val; + if (val === null || val === undefined) return null as unknown as Date; + if (typeof val !== 'string') return new Date(val as string | number); + let iso = val.trim(); + if (iso.includes(' ')) { + iso = iso.replace(' ', 'T'); + } + // If timezone offset is +HH or -HH (without minutes), append :00 + if (iso.includes(':') && /[+-]\d{2}$/.test(iso)) { + iso += ':00'; + } else if (!iso.endsWith('Z') && !/[+-]\d{2}(?::?\d{2})?$/.test(iso)) { + iso += 'Z'; + } + return new Date(iso); +} + +/** + * Maps array elements using the specified element parser. + */ +export function parsePgArray( + source: unknown, + elementParser: TypeParser = parseString, +): unknown[] { + if (Array.isArray(source)) { + return source.map(item => { + if (item === null || item === undefined) return null; + return Array.isArray(item) + ? parsePgArray(item, elementParser) + : elementParser(item); + }); + } + if (typeof source === 'string') { + const trimmed = source.trim(); + if (trimmed.startsWith('{') && trimmed.endsWith('}')) { + const inner = trimmed.slice(1, -1).trim(); + if (!inner) return []; + return inner + .split(/,(?=(?:(?:[^"]*"){2})*[^"]*$)/) + .map(s => s.trim().replace(/^"(.*)"$/, '$1')) + .map(s => (s.toUpperCase() === 'NULL' ? null : elementParser(s))); + } + } + return []; +} + +// ----------------------------------------------------------------------------- +// Binary Format Parsers (matches pg-types/lib/binaryParsers.js) +// ----------------------------------------------------------------------------- + +function parseBinaryBool(value: unknown): boolean { + if (Buffer.isBuffer(value)) { + return value[0] !== 0; + } + return parseBool(value); +} + +function parseBinaryFloat32(value: unknown): number { + if (Buffer.isBuffer(value) && value.length >= 4) { + return value.readFloatBE(0); + } + return parseFloatVal(value); +} + +function parseBinaryFloat64(value: unknown): number { + if (Buffer.isBuffer(value) && value.length >= 8) { + return value.readDoubleBE(0); + } + return parseFloatVal(value); +} + +function parseBinaryTimestamp(value: unknown): Date { + if (Buffer.isBuffer(value) && value.length >= 8) { + const rawValue = 0x100000000 * value.readInt32BE(0) + value.readUInt32BE(4); + // discard usecs and shift from PostgreSQL epoch (2000-01-01) to Unix epoch (1970-01-01) + return new Date(Math.round(rawValue / 1000) + 946684800000); + } + return parseTimestamp(value); +} + +function parseBinaryText(value: unknown): string { + if (Buffer.isBuffer(value)) { + return value.toString('utf8'); + } + return parseString(value); +} + +// ----------------------------------------------------------------------------- +// Parser Registries (matches pg-types textParsers and binaryParsers) +// ----------------------------------------------------------------------------- + +const textParsers: Record = { + [BuiltinOids.BOOL]: parseBool, + [BuiltinOids.BYTEA]: parseBytea, + [BuiltinOids.INT8]: parseString, + [BuiltinOids.TEXT]: parseString, + [BuiltinOids.JSON]: parseJson, + [BuiltinOids.FLOAT4]: parseFloatVal, + [BuiltinOids.FLOAT8]: parseFloatVal, + [BuiltinOids.VARCHAR]: parseString, + [BuiltinOids.DATE]: parseString, + [BuiltinOids.TIMESTAMP]: parseTimestamp, + [BuiltinOids.TIMESTAMPTZ]: parseTimestamp, + [BuiltinOids.INTERVAL]: parseString, + [BuiltinOids.NUMERIC]: parseString, + [BuiltinOids.UUID]: parseString, + [BuiltinOids.JSONB]: parseJson, + + // Standard Array OIDs supported by Cloud Spanner + 1000: (val: unknown) => parsePgArray(val, parseBool), + 1001: (val: unknown) => parsePgArray(val, parseBytea), + 1009: (val: unknown) => parsePgArray(val, parseString), + 1016: (val: unknown) => parsePgArray(val, parseString), + 1021: (val: unknown) => parsePgArray(val, parseFloatVal), + 1022: (val: unknown) => parsePgArray(val, parseFloatVal), + 1182: (val: unknown) => parsePgArray(val, parseString), + 1185: (val: unknown) => parsePgArray(val, parseTimestamp), + 1187: (val: unknown) => parsePgArray(val, parseString), + 1231: (val: unknown) => parsePgArray(val, parseString), + 2951: (val: unknown) => parsePgArray(val, parseString), + 3807: (val: unknown) => parsePgArray(val, parseJson), +}; + +const binaryParsers: Record = { + [BuiltinOids.BOOL]: parseBinaryBool, + [BuiltinOids.BYTEA]: parseBytea, + [BuiltinOids.INT8]: parseBinaryText, + [BuiltinOids.TEXT]: parseBinaryText, + [BuiltinOids.JSON]: parseJson, + [BuiltinOids.FLOAT4]: parseBinaryFloat32, + [BuiltinOids.FLOAT8]: parseBinaryFloat64, + [BuiltinOids.VARCHAR]: parseBinaryText, + [BuiltinOids.DATE]: parseBinaryText, + [BuiltinOids.TIMESTAMP]: parseBinaryTimestamp, + [BuiltinOids.TIMESTAMPTZ]: parseBinaryTimestamp, + [BuiltinOids.INTERVAL]: parseBinaryText, + [BuiltinOids.NUMERIC]: parseBinaryText, + [BuiltinOids.UUID]: parseBinaryText, + [BuiltinOids.JSONB]: parseJson, +}; + +const defaultTypeParsers: Record> = { + text: textParsers, + binary: binaryParsers, +}; + +/** + * TypeOverrides provides a scoped type parser and encoder registry. + * Matches `pg-types.TypeOverrides` and allows overriding type parsers per query, client, or globally. + */ +export class TypeOverrides implements ITypeOverrides { + private readonly _types: { + text: Map; + binary: Map; + [format: string]: Map; + }; + private readonly _parent?: ITypeOverrides; + + /** Standard PostgreSQL catalog OID constants matching `pg-types.builtins`. */ + public readonly builtins = BuiltinOids; + + constructor(parent?: ITypeOverrides) { + this._types = { + text: new Map(), + binary: new Map(), + }; + this._parent = parent; + } + + /** + * Retrieves the type parser registered for a specific PostgreSQL OID. + * + * @param oid - PostgreSQL Object Identifier number (e.g. 20 for INT8, 1184 for TIMESTAMPTZ). + * @param format - Formatting mode ('text' or 'binary'). Defaults to 'text'. + * @returns Parsing function converting wire string or buffer into JavaScript value. + */ + public getTypeParser(oid: number | string, format = 'text'): TypeParser { + const numOid = typeof oid === 'string' ? Number(oid) : oid; + if (typeof numOid !== 'number' || Number.isNaN(numOid)) { + throw new TypeError( + `Invalid PostgreSQL OID: "${oid}". OID must be numeric.`, + ); + } + const fmt = format || 'text'; + // 1. Explicitly registered parser in local overrides + const local = this._types[fmt]?.get(numOid); + if (local) { + return local; + } + // 2. Fallback to parent TypeOverrides instance if defined + if (this._parent) { + return this._parent.getTypeParser(numOid, fmt); + } + // 3. Fallback to global built-in defaults + return defaultTypeParsers[fmt]?.[numOid] || parseString; + } + + /** + * Registers a custom type parser function for a specific PostgreSQL OID. + * + * @param oid - PostgreSQL Object Identifier number. + * @param formatOrFn - 'text' or 'binary' format string, or parser function. + * @param fn - Parser function if format string was passed as second argument. + */ + public setTypeParser( + oid: number | string, + formatOrFn: string | TypeParser, + fn?: TypeParser, + ): void { + const numOid = typeof oid === 'string' ? Number(oid) : oid; + if (typeof numOid !== 'number' || Number.isNaN(numOid)) { + throw new TypeError( + `Invalid PostgreSQL OID: "${oid}". OID must be numeric.`, + ); + } + let format = 'text'; + let parser: TypeParser; + if (typeof formatOrFn === 'function') { + parser = formatOrFn; + } else { + format = formatOrFn || 'text'; + parser = fn!; + } + if (typeof parser !== 'function') { + throw new TypeError('Type parser must be a function'); + } + if (!this._types[format]) { + this._types[format] = new Map(); + } + this._types[format].set(numOid, parser); + } + + /** + * Helper parsing a PostgreSQL array string using the specified element parser. + */ + public arrayParser(source: unknown, elementParser?: TypeParser): unknown[] { + return parsePgArray(source, elementParser); + } +} + +/** + * Global default TypeOverrides instance matching `pg.types`. + */ +export const types = new TypeOverrides(); + +// ----------------------------------------------------------------------------- +// TypeCode & Annotation to PostgreSQL OID Lookup Tables +// ----------------------------------------------------------------------------- + +const SPANNER_TYPE_TO_PG_OID: Record = { + BOOL: BuiltinOids.BOOL, // 16 + INT64: BuiltinOids.INT8, // 20 + FLOAT32: BuiltinOids.FLOAT4, // 700 + FLOAT64: BuiltinOids.FLOAT8, // 701 + TIMESTAMP: BuiltinOids.TIMESTAMPTZ, // 1184 + DATE: BuiltinOids.DATE, // 1082 + STRING: BuiltinOids.TEXT, // 25 + BYTES: BuiltinOids.BYTEA, // 17 + NUMERIC: BuiltinOids.NUMERIC, // 1700 + JSON: BuiltinOids.JSONB, // 3802 + INTERVAL: BuiltinOids.INTERVAL, // 1186 + UUID: BuiltinOids.UUID, // 2950 +}; + +const SPANNER_ARRAY_ELEM_TO_PG_ARRAY_OID: Record = { + BOOL: 1000, // bool[] + INT64: 1016, // int8[] + FLOAT32: 1021, // float4[] + FLOAT64: 1022, // float8[] + TIMESTAMP: 1185, // timestamptz[] + DATE: 1182, // date[] + STRING: 1009, // text[] + BYTES: 1001, // bytea[] + NUMERIC: 1231, // numeric[] + JSON: 3807, // jsonb[] + INTERVAL: 1187, // interval[] + UUID: 2951, // uuid[] +}; + +const TYPE_ANNOTATION_TO_PG_OID: Record = { + PG_JSONB: BuiltinOids.JSONB, // 3802 + PG_NUMERIC: BuiltinOids.NUMERIC, // 1700 + PG_OID: 26, // PostgreSQL OID type +}; + +/** + * Maps a Spanner `TypeCode` to its corresponding PostgreSQL catalog OID number. + * + * @param code - Spanner TypeCode numeric enum value or string name. + * @param arrayElementType - Optional array element type descriptor when code is ARRAY. + * @param typeAnnotation - Optional TypeAnnotationCode (e.g. PG_JSONB, PG_NUMERIC, PG_OID). + * @returns Numeric PostgreSQL catalog OID (e.g. 20 for INT8, 25 for TEXT, 1184 for TIMESTAMPTZ). + */ +export function mapSpannerTypeToPgOid( + code: number | string | undefined | null, + arrayElementType?: {code?: number | string | null} | null, + typeAnnotation?: number | string | null, +): number { + if (typeAnnotation !== undefined && typeAnnotation !== null) { + const annotName = ( + typeof typeAnnotation === 'number' + ? TypeAnnotationCode[typeAnnotation] + : String(typeAnnotation) + )?.toUpperCase(); + + const annotatedOid = TYPE_ANNOTATION_TO_PG_OID[annotName]; + if (annotatedOid) return annotatedOid; + } + + if (code === undefined || code === null) { + return BuiltinOids.TEXT; + } + + const codeName = ( + typeof code === 'number' ? TypeCode[code] || String(code) : String(code) + ).toUpperCase(); + + if (codeName === 'ARRAY') { + const elemCode = arrayElementType?.code; + const elemName = ( + typeof elemCode === 'number' + ? TypeCode[elemCode] || String(elemCode) + : String(elemCode || 'STRING') + ).toUpperCase(); + return SPANNER_ARRAY_ELEM_TO_PG_ARRAY_OID[elemName] ?? 1009; + } + + return SPANNER_TYPE_TO_PG_OID[codeName] ?? BuiltinOids.TEXT; +} diff --git a/handwritten/spanner-driver/src/lib/pg/utilities.ts b/handwritten/spanner-driver/src/lib/pg/utilities.ts index 54cb4140e08d..4892efda6a81 100644 --- a/handwritten/spanner-driver/src/lib/pg/utilities.ts +++ b/handwritten/spanner-driver/src/lib/pg/utilities.ts @@ -51,3 +51,21 @@ export const escapeLiteral = (str: string): string => { } return "'" + str.replace(/'/g, "''") + "'"; }; + +/** + * Prepares a parameter value for PostgreSQL dialect queries by unwrapping + * objects implementing custom `.toPostgres()` serialization hooks (e.g. Knex/ORMs). + * + * @param val - Parameter value to prepare. + * @returns Serialized or unwrapped parameter value. + */ +export function preparePgValue(val: unknown): unknown { + if ( + typeof val === 'object' && + val !== null && + typeof (val as {toPostgres?: unknown}).toPostgres === 'function' + ) { + return (val as {toPostgres: () => unknown}).toPostgres(); + } + return val; +} diff --git a/handwritten/spanner-driver/src/lib/pool.ts b/handwritten/spanner-driver/src/lib/pool.ts index 99b9e86b11fe..d3b3d504b85f 100644 --- a/handwritten/spanner-driver/src/lib/pool.ts +++ b/handwritten/spanner-driver/src/lib/pool.ts @@ -168,8 +168,7 @@ export class Pool extends EventEmitter { const isExpiredByLifetime = this.options.maxLifetimeSeconds > 0 && meta !== undefined && - (Date.now() - meta.createdAt) / 1000 >= - this.options.maxLifetimeSeconds; + (Date.now() - meta.createdAt) / 1000 >= this.options.maxLifetimeSeconds; if (isExpiredByLifetime || !item.client.isConnected) { this.removeClient(item.client); @@ -199,10 +198,7 @@ export class Pool extends EventEmitter { }); try { - await this.connectAndInit( - client, - this.options.connectionTimeoutMillis, - ); + await this.connectAndInit(client, this.options.connectionTimeoutMillis); // Re-check if pool has been closed while waiting for connection or onConnect if (this.isEnding || this.isEnded) { @@ -377,7 +373,10 @@ export class Pool extends EventEmitter { this.onIdleTimeout(client); }, this.options.idleTimeoutMillis); - if (this.options.allowExitOnIdle && typeof idleTimer.unref === 'function') { + if ( + this.options.allowExitOnIdle && + typeof idleTimer.unref === 'function' + ) { idleTimer.unref(); } } diff --git a/handwritten/spanner-driver/src/lib/query.ts b/handwritten/spanner-driver/src/lib/query.ts index 0cd8649bcac7..9a46e77889d3 100644 --- a/handwritten/spanner-driver/src/lib/query.ts +++ b/handwritten/spanner-driver/src/lib/query.ts @@ -13,7 +13,7 @@ // limitations under the License. import {EventEmitter} from 'events'; -import {QueryConfig} from './types.js'; +import {ITypeOverrides, QueryConfig} from './types.js'; /** * Node callback function signature receiving `(err, result)`. @@ -44,7 +44,7 @@ export class Query extends EventEmitter { public rowMode?: 'array' | 'object'; /** Optional custom type parser override registry. */ - public types?: unknown; + public types?: ITypeOverrides; /** Internal promise backing Thenable async/await integration. */ private promise!: Promise; diff --git a/handwritten/spanner-driver/src/lib/types.ts b/handwritten/spanner-driver/src/lib/types.ts index 46f41f492419..d1b04a161c58 100644 --- a/handwritten/spanner-driver/src/lib/types.ts +++ b/handwritten/spanner-driver/src/lib/types.ts @@ -12,14 +12,60 @@ // See the License for the specific language governing permissions and // limitations under the License. +import {DEFAULT_DIALECT, Dialect} from './constants.js'; +import {types as defaultPgTypes} from './pg/types.js'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type TypeParser = (value: any) => T; + +/** + * Returns the default type overrides registry for the given SQL dialect. + * + * @param dialect - SQL dialect ('pg' or 'googlesql'). Defaults to 'pg'. + * @returns Default type overrides registry implementing ITypeOverrides. + */ +export function getDefaultTypeOverrides( + dialect: Dialect = DEFAULT_DIALECT, +): ITypeOverrides { + switch (dialect) { + case 'pg': + default: + return defaultPgTypes; + } +} + +/** + * Dialect-agnostic type codec interface for decoding query results and encoding parameters. + */ +export interface ITypeOverrides { + /** + * Retrieves parser function for a given column type ID (numeric OID for PG, string type for GoogleSQL). + */ + getTypeParser(typeId: number | string, format?: string): TypeParser; + + /** + * Registers a custom type parser function. + */ + setTypeParser( + typeId: number | string, + formatOrFn: string | TypeParser, + fn?: TypeParser, + ): void; + + /** + * Helper method for parsing array string literals or pre-parsed arrays into JavaScript arrays. + */ + arrayParser?(source: unknown, elementParser?: TypeParser): unknown[]; +} + /** * Field metadata descriptor for query result set columns. */ export interface FieldDef { /** Column name returned in query result set. */ name: string; - /** PostgreSQL Object Identifier (OID) data type code. */ - dataTypeID: number; + /** Column data type code (PostgreSQL numeric OID or GoogleSQL string descriptor). */ + dataTypeID: number | string; } /** @@ -53,5 +99,5 @@ export interface QueryConfig { */ rowMode?: 'array' | 'object'; /** Custom type parser registry hook for overriding OID data type decoding. */ - types?: unknown; + types?: ITypeOverrides; } diff --git a/handwritten/spanner-driver/system-test/driver.ts b/handwritten/spanner-driver/system-test/driver.ts new file mode 100644 index 000000000000..1c1daf239b1a --- /dev/null +++ b/handwritten/spanner-driver/system-test/driver.ts @@ -0,0 +1,940 @@ +// 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 {after, before, describe, it} from 'mocha'; +import {Spanner, protos} from '@google-cloud/spanner'; +import {BuiltinOids, Client, Pool, QueryResult} from '../src/index.js'; + +describe('Spanner Driver System Tests (PostgreSQL Dialect)', function () { + this.timeout(180000); // 3 minutes to allow Spanner DDL / Database creation + + const rawConn = + process.env.SPANNER_CONNECTION_STRING || + (process.env.SPANNER_EMULATOR_HOST + ? `projects/test-project/instances/test-instance/databases/test-database;host=${process.env.SPANNER_EMULATOR_HOST};usePlainText=true` + : undefined); + + const instanceEnv = process.env.SPANNER_INSTANCE; + + if (!rawConn && !instanceEnv) { + it.skip('Skipping system tests: Set SPANNER_CONNECTION_STRING, SPANNER_INSTANCE, or SPANNER_EMULATOR_HOST to run', () => {}); + return; + } + + // Parse project, instance, and database + let projectId = process.env.SPANNER_PROJECT || process.env.GCLOUD_PROJECT; + let instanceId: string | undefined; + let dbName: string | undefined; + let connectionParams = ''; + + if (rawConn) { + const [pathPart, ...restParams] = rawConn.split(';'); + connectionParams = restParams.length ? ';' + restParams.join(';') : ''; + const match = pathPart.match( + /projects\/([^/]+)\/instances\/([^/]+)(?:\/databases\/([^/]+))?/, + ); + if (match) { + projectId = projectId || match[1]; + instanceId = match[2]; + dbName = match[3]; + } + } else if (instanceEnv) { + const match = instanceEnv.match(/projects\/([^/]+)\/instances\/([^/]+)/); + if (match) { + projectId = projectId || match[1]; + instanceId = match[2]; + } else { + instanceId = instanceEnv; + } + } + + // Determine whether to dynamically create and drop a temporary test database + const shouldCreateDb = + process.env.SPANNER_CREATE_TEMP_DB === 'true' || + !dbName || + Boolean(instanceEnv && !rawConn); + + if (shouldCreateDb) { + dbName = `test_pg_${Date.now()}`; + } + + const finalConnectionString = `projects/${projectId}/instances/${instanceId}/databases/${dbName}${connectionParams}`; + let spannerAdminClient: + | ReturnType + | undefined; + let client: Client; + let pool: Pool; + + before(async () => { + if (shouldCreateDb) { + console.log( + `Creating temporary Spanner PostgreSQL database: ${dbName}...`, + ); + const spanner = new Spanner({projectId}); + spannerAdminClient = spanner.getDatabaseAdminClient(); + const parent = spannerAdminClient.instancePath(projectId!, instanceId!); + + const [op] = await spannerAdminClient.createDatabase({ + parent, + createStatement: `CREATE DATABASE "${dbName}"`, + databaseDialect: + protos.google.spanner.admin.database.v1.DatabaseDialect.POSTGRESQL, + }); + await op.promise(); + + const [ddlOp] = await spannerAdminClient.updateDatabaseDdl({ + database: spannerAdminClient.databasePath( + projectId!, + instanceId!, + dbName!, + ), + statements: [ + `CREATE TABLE Singers ( + SingerId bigint NOT NULL, + FirstName character varying(1024), + LastName character varying(1024), + BirthDate date, + LastModified timestamptz, + Rating float8, + Active boolean, + Revenues numeric, + Metadata jsonb, + Tags text[], + PRIMARY KEY (SingerId) + );`, + `CREATE TABLE AllTypes ( + Id bigint NOT NULL, + ColBool boolean, + ColBytea bytea, + ColInt8 bigint, + ColFloat4 float4, + ColFloat8 float8, + ColNumeric numeric, + ColText text, + ColVarchar character varying(1024), + ColDate date, + ColTimestamp timestamptz, + ColJsonb jsonb, + ColUuid uuid, + ArrBool boolean[], + ArrBytea bytea[], + ArrInt8 bigint[], + ArrFloat4 float4[], + ArrFloat8 float8[], + ArrNumeric numeric[], + ArrText text[], + ArrDate date[], + ArrTimestamp timestamptz[], + ArrJsonb jsonb[], + ArrUuid uuid[], + PRIMARY KEY (Id) + );`, + ], + }); + await ddlOp.promise(); + console.log(`Database and schema created successfully: ${dbName}`); + } + + client = new Client({connectionString: finalConnectionString}); + await client.connect(); + + pool = new Pool({ + connectionString: finalConnectionString, + max: 5, + idleTimeoutMillis: 10000, + }); + + // 1. Create tables if using existing database and tables do not exist + if (!shouldCreateDb) { + try { + await client.query(` + CREATE TABLE IF NOT EXISTS Singers ( + SingerId bigint NOT NULL, + FirstName character varying(1024), + LastName character varying(1024), + BirthDate date, + LastModified timestamptz, + Rating float8, + Active boolean, + Revenues numeric, + Metadata jsonb, + Tags text[], + PRIMARY KEY (SingerId) + ) + `); + await client.query(` + CREATE TABLE IF NOT EXISTS AllTypes ( + Id bigint NOT NULL, + ColBool boolean, + ColBytea bytea, + ColInt8 bigint, + ColFloat4 float4, + ColFloat8 float8, + ColNumeric numeric, + ColText text, + ColVarchar character varying(1024), + ColDate date, + ColTimestamp timestamptz, + ColJsonb jsonb, + ColUuid uuid, + ArrBool boolean[], + ArrBytea bytea[], + ArrInt8 bigint[], + ArrFloat4 float4[], + ArrFloat8 float8[], + ArrNumeric numeric[], + ArrText text[], + ArrDate date[], + ArrTimestamp timestamptz[], + ArrJsonb jsonb[], + ArrUuid uuid[], + PRIMARY KEY (Id) + ) + `); + } catch { + // Table may already exist or DDL handled externally + } + } + + // 2. Seed initial test data inside a read-write transaction + try { + await client.query('BEGIN'); + await client.query('DELETE FROM Singers WHERE SingerId IN (1, 2, 3, 4)'); + await client.query( + ` + INSERT INTO Singers ( + SingerId, FirstName, LastName, BirthDate, LastModified, Rating, Active, Revenues, Metadata, Tags + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10 + ) + `, + [ + 1, + 'Marc', + 'Richards', + '1980-01-05', + new Date('2023-01-01T12:00:00.000Z'), + 4.8, + true, + '125000.50', + {genre: 'rock'}, + ['rock', 'classic'], + ], + ); + await client.query( + ` + INSERT INTO Singers ( + SingerId, FirstName, LastName, BirthDate, LastModified, Rating, Active, Revenues, Metadata, Tags + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10 + ) + `, + [ + 2, + 'Catalina', + 'Smith', + '1992-07-15', + new Date('2023-02-01T15:30:00.000Z'), + 4.9, + false, + '95000.00', + {genre: 'pop'}, + ['pop', 'dance'], + ], + ); + + // Seed AllTypes table + await client.query('DELETE FROM AllTypes WHERE Id = 1'); + await client.query( + `INSERT INTO AllTypes ( + Id, ColBool, ColBytea, ColInt8, ColFloat4, ColFloat8, ColNumeric, ColText, ColVarchar, + ColDate, ColTimestamp, ColJsonb, ColUuid, + ArrBool, ArrBytea, ArrInt8, ArrFloat4, ArrFloat8, ArrNumeric, ArrText, + ArrDate, ArrTimestamp, ArrJsonb, ArrUuid + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, + $10, $11, $12, $13, + $14, $15, $16, $17, $18, $19, $20, + $21, $22, $23, $24 + )`, + [ + 1, + true, + Buffer.from('Spanner Binary Data'), + BigInt('9223372036854775807'), + 3.14, + 2.718281828459045, + '123456789.987654321', + 'Hello Spanner PostgreSQL', + 'Varchar sample', + '2026-08-14', + new Date('2026-08-14T12:00:00.000Z'), + {name: 'Spanner', dialect: 'postgresql'}, + 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', + [true, false, true], + [Buffer.from('bin1'), Buffer.from('bin2')], + [100, 200, 300], + [1.1, 2.2], + [3.1415, 2.7182], + ['10.5', '20.25', '30.125'], + ['alpha', 'beta', 'gamma'], + ['2026-01-01', '2026-06-01'], + [ + new Date('2026-01-01T00:00:00.000Z'), + new Date('2026-06-01T00:00:00.000Z'), + ], + [{k: 'v1'}, {k: 'v2'}], + [ + 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', + 'b1ffcd00-0d1c-5fa9-cc7e-7cc0ce491b22', + ], + ], + ); + await client.query('COMMIT'); + const countRes = await client.query( + 'SELECT count(*) as count FROM Singers', + ); + console.log( + 'Seeded Singers successfully. Current row count:', + countRes.rows, + ); + } catch (seedErr) { + console.error('FAILED TO SEED DATA:', seedErr); + try { + await client.query('ROLLBACK'); + } catch { + // ignore rollback error + } + throw seedErr; + } + }); + + after(async () => { + try { + if (client && client.isConnected) { + await client.end(); + } + if (pool) { + await pool.end(); + } + } catch { + // Best effort cleanup + } + + if ( + shouldCreateDb && + spannerAdminClient && + projectId && + instanceId && + dbName + ) { + try { + console.log(`Dropping temporary test database: ${dbName}...`); + await spannerAdminClient.dropDatabase({ + database: spannerAdminClient.databasePath( + projectId, + instanceId, + dbName, + ), + }); + console.log(`Successfully dropped test database: ${dbName}`); + } catch (dropErr) { + console.warn( + `Warning: Failed to drop test database ${dbName}:`, + dropErr, + ); + } + } + }); + + describe('Data Types & Codecs', () => { + describe('Scalar Types', () => { + it('should query and decode all scalar column types from table', async () => { + const res = await client.query( + 'SELECT SingerId, FirstName, LastName, BirthDate, LastModified, Rating, Active, Revenues, Metadata FROM Singers WHERE SingerId = 1', + ); + assert.strictEqual(res.rowCount, 1); + const row = res.rows[0]; + assert.ok(row, 'Expected row to be returned'); + assert.strictEqual(String(row.singerid || row.SingerId), '1'); + assert.strictEqual(row.firstname || row.FirstName, 'Marc'); + assert.strictEqual(row.lastname || row.LastName, 'Richards'); + assert.strictEqual(row.birthdate || row.BirthDate, '1980-01-05'); + assert.ok( + (row.lastmodified || row.LastModified) instanceof Date, + 'Expected LastModified to be Date instance', + ); + assert.strictEqual(row.active ?? row.Active, true); + assert.strictEqual(Number(row.rating || row.Rating), 4.8); + assert.strictEqual(Number(row.revenues || row.Revenues), 125000.5); + + const meta = (row.metadata || row.Metadata) as + | {genre?: string} + | undefined; + assert.strictEqual(meta?.genre, 'rock'); + }); + + it('should execute parameterized query with numeric parameter ($1)', async () => { + const res = await client.query( + 'SELECT SingerId, FirstName, Active FROM Singers WHERE SingerId = $1', + [2], + ); + assert.strictEqual(res.rowCount, 1); + const row = res.rows[0]; + assert.strictEqual(String(row.singerid || row.SingerId), '2'); + assert.strictEqual(row.firstname || row.FirstName, 'Catalina'); + assert.strictEqual(row.active ?? row.Active, false); + }); + + it('should execute parameterized query with string parameter ($1)', async () => { + const res = await client.query( + 'SELECT SingerId, FirstName, LastName FROM Singers WHERE LastName = $1', + ['Richards'], + ); + assert.strictEqual(res.rowCount, 1); + const row = res.rows[0]; + assert.strictEqual(row.firstname || row.FirstName, 'Marc'); + }); + + it('should execute parameterized query with date parameter ($1::date)', async () => { + const res = await client.query( + 'SELECT SingerId, FirstName FROM Singers WHERE BirthDate = $1::date', + ['1980-01-05'], + ); + assert.strictEqual(res.rowCount, 1); + assert.strictEqual( + res.rows[0].firstname || res.rows[0].FirstName, + 'Marc', + ); + }); + + it('should execute parameterized query with timestamptz Date parameter ($1)', async () => { + const res = await client.query( + 'SELECT SingerId, FirstName FROM Singers WHERE LastModified = $1', + [new Date('2023-01-01T12:00:00.000Z')], + ); + assert.strictEqual(res.rowCount, 1); + assert.strictEqual( + res.rows[0].firstname || res.rows[0].FirstName, + 'Marc', + ); + }); + + it('should execute parameterized query with boolean parameter ($1)', async () => { + const res = await client.query( + 'SELECT SingerId, FirstName FROM Singers WHERE Active = $1', + [true], + ); + assert.strictEqual(res.rowCount, 1); + assert.strictEqual( + res.rows[0].firstname || res.rows[0].FirstName, + 'Marc', + ); + }); + + it('should execute parameterized query with numeric/decimal parameter ($1::numeric)', async () => { + const res = await client.query( + 'SELECT SingerId, FirstName FROM Singers WHERE Revenues = $1::numeric', + ['125000.50'], + ); + assert.strictEqual(res.rowCount, 1); + assert.strictEqual( + res.rows[0].firstname || res.rows[0].FirstName, + 'Marc', + ); + }); + + it('should execute parameterized query filtering jsonb column (Metadata ->> $1)', async () => { + const res = await client.query( + "SELECT SingerId, FirstName FROM Singers WHERE Metadata ->> 'genre' = $1", + ['rock'], + ); + assert.strictEqual(res.rowCount, 1); + assert.strictEqual( + res.rows[0].firstname || res.rows[0].FirstName, + 'Marc', + ); + }); + + it('should encode and decode jsonb parameter ($1::jsonb)', async () => { + const res = await client.query('SELECT $1::jsonb as payload', [ + {genre: 'rock', tracks: 12}, + ]); + assert.strictEqual(res.rowCount, 1); + assert.deepStrictEqual(res.rows[0].payload, { + genre: 'rock', + tracks: 12, + }); + }); + + it('should execute parameterized query with bytea Buffer parameter ($1::bytea)', async () => { + const payload = Buffer.from('Spanner Binary Test Data'); + const res = await client.query('SELECT $1::bytea as bin_data', [ + payload, + ]); + assert.strictEqual(res.rowCount, 1); + const returnedBuf = res.rows[0].bin_data as Buffer; + assert.ok(Buffer.isBuffer(returnedBuf)); + assert.deepStrictEqual(returnedBuf, payload); + }); + + it('should read and decode all table-storable scalar column types from AllTypes table', async () => { + const res = await client.query( + 'SELECT ColBool, ColBytea, ColInt8, ColFloat4, ColFloat8, ColNumeric, ColText, ColVarchar, ColDate, ColTimestamp, ColJsonb, ColUuid FROM AllTypes WHERE Id = 1', + ); + assert.strictEqual(res.rowCount, 1); + const row = res.rows[0]; + assert.ok(row, 'Expected row to be returned'); + assert.strictEqual(row.colbool ?? row.ColBool, true); + const bytea = (row.colbytea || row.ColBytea) as Buffer; + assert.ok(Buffer.isBuffer(bytea)); + assert.strictEqual(bytea.toString(), 'Spanner Binary Data'); + assert.strictEqual( + String(row.colint8 || row.ColInt8), + '9223372036854775807', + ); + assert.ok( + Math.abs(Number(row.colfloat4 || row.ColFloat4) - 3.14) < 0.001, + ); + assert.ok( + Math.abs(Number(row.colfloat8 || row.ColFloat8) - 2.718281828459045) < + 0.000001, + ); + assert.strictEqual( + String(row.colnumeric || row.ColNumeric), + '123456789.987654321', + ); + assert.strictEqual( + row.coltext || row.ColText, + 'Hello Spanner PostgreSQL', + ); + assert.strictEqual(row.colvarchar || row.ColVarchar, 'Varchar sample'); + assert.strictEqual(row.coldate || row.ColDate, '2026-08-14'); + assert.ok( + (row.coltimestamp || row.ColTimestamp) instanceof Date, + 'Expected ColTimestamp to be Date instance', + ); + assert.deepStrictEqual(row.coljsonb || row.ColJsonb, { + name: 'Spanner', + dialect: 'postgresql', + }); + assert.strictEqual( + row.coluuid || row.ColUuid, + 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', + ); + }); + + it('should execute parameterized query with uuid parameter ($1::uuid)', async () => { + const uuidVal = 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11'; + const res = await client.query('SELECT $1::uuid as uuid_val', [ + uuidVal, + ]); + assert.strictEqual(res.rowCount, 1); + assert.strictEqual(res.rows[0].uuid_val, uuidVal); + }); + + it('should execute query with interval expression and date arithmetic', async () => { + const res = await client.query( + "SELECT CAST('1 year 2 months 3 days' AS INTERVAL) as interval_val, ('2026-01-01 00:00:00+00'::timestamptz + CAST('1 year 2 months 3 days' AS INTERVAL)) as shifted_time", + ); + assert.strictEqual(res.rowCount, 1); + assert.ok( + typeof res.rows[0].interval_val === 'string' && + res.rows[0].interval_val.length > 0, + ); + assert.ok(res.rows[0].shifted_time instanceof Date); + }); + + it('should execute parameterized query with float4 parameter ($1::float4)', async () => { + const res = await client.query('SELECT $1::float4 as f4_val', [3.14]); + assert.strictEqual(res.rowCount, 1); + assert.ok(Math.abs(Number(res.rows[0].f4_val) - 3.14) < 0.001); + }); + + it('should execute parameterized query with float8 parameter ($1::float8)', async () => { + const res = await client.query('SELECT $1::float8 as f8_val', [ + 2.718281828459045, + ]); + assert.strictEqual(res.rowCount, 1); + assert.ok( + Math.abs(Number(res.rows[0].f8_val) - 2.718281828459045) < 0.000001, + ); + }); + }); + + describe('Array Types', () => { + it('should read and decode array column (Tags text[]) from Singers table', async () => { + const res = await client.query( + 'SELECT SingerId, Tags FROM Singers WHERE SingerId = 1', + ); + assert.strictEqual(res.rowCount, 1); + assert.deepStrictEqual(res.rows[0].tags || res.rows[0].Tags, [ + 'rock', + 'classic', + ]); + }); + + it('should read and decode all table-storable array column types from AllTypes table', async () => { + const res = await client.query( + 'SELECT ArrBool, ArrBytea, ArrInt8, ArrFloat4, ArrFloat8, ArrNumeric, ArrText, ArrDate, ArrTimestamp, ArrJsonb, ArrUuid FROM AllTypes WHERE Id = 1', + ); + assert.strictEqual(res.rowCount, 1); + const row = res.rows[0]; + assert.deepStrictEqual(row.arrbool || row.ArrBool, [true, false, true]); + const byteaArr = (row.arrbytea || row.ArrBytea) as Buffer[]; + assert.ok(Array.isArray(byteaArr)); + assert.strictEqual(byteaArr[0].toString(), 'bin1'); + assert.strictEqual(byteaArr[1].toString(), 'bin2'); + assert.deepStrictEqual(row.arrint8 || row.ArrInt8, [ + '100', + '200', + '300', + ]); + const float4Arr = (row.arrfloat4 || row.ArrFloat4) as number[]; + assert.ok(Math.abs(float4Arr[0] - 1.1) < 0.01); + assert.ok(Math.abs(float4Arr[1] - 2.2) < 0.01); + const float8Arr = (row.arrfloat8 || row.ArrFloat8) as number[]; + assert.ok(Math.abs(float8Arr[0] - 3.1415) < 0.0001); + assert.ok(Math.abs(float8Arr[1] - 2.7182) < 0.0001); + assert.deepStrictEqual(row.arrnumeric || row.ArrNumeric, [ + '10.5', + '20.25', + '30.125', + ]); + assert.deepStrictEqual(row.arrtext || row.ArrText, [ + 'alpha', + 'beta', + 'gamma', + ]); + assert.deepStrictEqual(row.arrdate || row.ArrDate, [ + '2026-01-01', + '2026-06-01', + ]); + const tsArr = (row.arrtimestamp || row.ArrTimestamp) as Date[]; + assert.ok(tsArr[0] instanceof Date); + assert.ok(tsArr[1] instanceof Date); + assert.deepStrictEqual(row.arrjsonb || row.ArrJsonb, [ + {k: 'v1'}, + {k: 'v2'}, + ]); + assert.deepStrictEqual(row.arruuid || row.ArrUuid, [ + 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', + 'b1ffcd00-0d1c-5fa9-cc7e-7cc0ce491b22', + ]); + }); + + it('should query table rows using array membership filter ($1 = ANY(Tags))', async () => { + const res = await client.query( + 'SELECT SingerId, FirstName FROM Singers WHERE $1 = ANY(Tags)', + ['rock'], + ); + assert.strictEqual(res.rowCount, 1); + assert.strictEqual( + res.rows[0].firstname || res.rows[0].FirstName, + 'Marc', + ); + }); + + it('should execute parameterized query with numeric array parameter ($1 = ANY)', async () => { + const res = await client.query( + 'SELECT SingerId, FirstName FROM Singers WHERE SingerId = ANY($1) ORDER BY SingerId', + [[1, 2]], + ); + assert.strictEqual(res.rowCount, 2); + assert.strictEqual(res.rows.length, 2); + assert.strictEqual( + String(res.rows[0].singerid || res.rows[0].SingerId), + '1', + ); + assert.strictEqual( + String(res.rows[1].singerid || res.rows[1].SingerId), + '2', + ); + }); + + it('should execute parameterized query with string array parameter ($1 = ANY)', async () => { + const res = await client.query( + 'SELECT SingerId, LastName FROM Singers WHERE LastName = ANY($1) ORDER BY SingerId', + [['Richards', 'Smith']], + ); + assert.strictEqual(res.rowCount, 2); + assert.strictEqual( + res.rows[0].lastname || res.rows[0].LastName, + 'Richards', + ); + assert.strictEqual( + res.rows[1].lastname || res.rows[1].LastName, + 'Smith', + ); + }); + + it('should encode and decode array types ($1::text[] and $2::bigint[])', async () => { + const res = await client.query( + 'SELECT $1::text[] as text_arr, $2::bigint[] as int_arr', + [ + ['apple', 'banana', 'cherry'], + [10, 20, 30], + ], + ); + assert.strictEqual(res.rowCount, 1); + assert.deepStrictEqual(res.rows[0].text_arr, [ + 'apple', + 'banana', + 'cherry', + ]); + assert.deepStrictEqual(res.rows[0].int_arr, ['10', '20', '30']); + }); + + it('should query AllTypes rows using array membership filter ($1 = ANY(ArrUuid))', async () => { + const res = await client.query( + 'SELECT Id FROM AllTypes WHERE $1 = ANY(ArrUuid)', + ['a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11'], + ); + assert.strictEqual(res.rowCount, 1); + assert.strictEqual(String(res.rows[0].id || res.rows[0].Id), '1'); + }); + + it('should query AllTypes rows using numeric array membership filter ($1 = ANY(ArrInt8))', async () => { + const res = await client.query( + 'SELECT Id FROM AllTypes WHERE $1 = ANY(ArrInt8)', + [200], + ); + assert.strictEqual(res.rowCount, 1); + assert.strictEqual(String(res.rows[0].id || res.rows[0].Id), '1'); + }); + }); + + describe('Field Metadata & PostgreSQL Catalog OIDs', () => { + it('should map Spanner column metadata to exact PostgreSQL catalog OIDs (BuiltinOids)', async () => { + const res = await client.query('SELECT * FROM AllTypes WHERE Id = 1'); + assert.strictEqual(res.rowCount, 1); + assert.ok(res.fields && res.fields.length > 0); + + const fieldMap = new Map( + res.fields.map(f => [f.name.toLowerCase(), f.dataTypeID]), + ); + + // Assert scalar PostgreSQL OIDs from table + assert.strictEqual(fieldMap.get('id'), BuiltinOids.INT8); + assert.strictEqual(fieldMap.get('colbool'), BuiltinOids.BOOL); + assert.strictEqual(fieldMap.get('colbytea'), BuiltinOids.BYTEA); + assert.strictEqual(fieldMap.get('colint8'), BuiltinOids.INT8); + assert.strictEqual(fieldMap.get('colfloat4'), BuiltinOids.FLOAT4); + assert.strictEqual(fieldMap.get('colfloat8'), BuiltinOids.FLOAT8); + assert.strictEqual(fieldMap.get('colnumeric'), BuiltinOids.NUMERIC); + assert.strictEqual(fieldMap.get('coltext'), BuiltinOids.TEXT); + assert.strictEqual(fieldMap.get('coldate'), BuiltinOids.DATE); + assert.strictEqual( + fieldMap.get('coltimestamp'), + BuiltinOids.TIMESTAMPTZ, + ); + assert.strictEqual(fieldMap.get('coljsonb'), BuiltinOids.JSONB); + assert.strictEqual(fieldMap.get('coluuid'), BuiltinOids.UUID); + + // Assert Array OIDs from table + assert.strictEqual(fieldMap.get('arrbool'), 1000); + assert.strictEqual(fieldMap.get('arrbytea'), 1001); + assert.strictEqual(fieldMap.get('arrint8'), 1016); + assert.strictEqual(fieldMap.get('arrfloat4'), 1021); + assert.strictEqual(fieldMap.get('arrfloat8'), 1022); + assert.strictEqual(fieldMap.get('arrnumeric'), 1231); + assert.strictEqual(fieldMap.get('arrtext'), 1009); + assert.strictEqual(fieldMap.get('arrdate'), 1182); + assert.strictEqual(fieldMap.get('arrtimestamp'), 1185); + assert.strictEqual(fieldMap.get('arrjsonb'), 3807); + assert.strictEqual(fieldMap.get('arruuid'), 2951); + + // Assert interval OID via expression query + const ivalRes = await client.query( + "SELECT CAST('1 day' AS INTERVAL) as ival", + ); + const ivalFields = new Map( + ivalRes.fields.map(f => [f.name.toLowerCase(), f.dataTypeID]), + ); + assert.strictEqual(ivalFields.get('ival'), BuiltinOids.INTERVAL); + }); + }); + }); + + describe('Query Options & Features', () => { + describe('Row Formatting (rowMode)', () => { + it('should format rows as objects by default (rowMode: object)', async () => { + const res = await client.query( + 'SELECT SingerId, FirstName FROM Singers WHERE SingerId = 1', + ); + assert.strictEqual(res.rowCount, 1); + assert.strictEqual(typeof res.rows[0], 'object'); + assert.strictEqual(Array.isArray(res.rows[0]), false); + assert.strictEqual( + res.rows[0].firstname || res.rows[0].FirstName, + 'Marc', + ); + }); + + it('should format rows as positional arrays when rowMode is array', async () => { + const res = await client.query({ + text: 'SELECT SingerId, FirstName, LastName, Tags FROM Singers WHERE SingerId = 1', + rowMode: 'array', + }); + assert.strictEqual(res.rowCount, 1); + assert.deepStrictEqual(res.rows[0], [ + '1', + 'Marc', + 'Richards', + ['rock', 'classic'], + ]); + }); + }); + + describe('Streaming Queries (EventEmitter)', () => { + it('should stream rows and fields events for Singers query', async () => { + const q = client.query('SELECT * FROM Singers ORDER BY SingerId'); + let fieldsReceived = false; + const rows: unknown[] = []; + + void q.on('fields', fields => { + fieldsReceived = true; + assert.ok(fields.length >= 2); + }); + void q.on('row', (row, currentResult) => { + rows.push(row); + assert.ok(currentResult); + assert.ok(currentResult.fields.length >= 2); + }); + + const res = (await q) as QueryResult; + assert.strictEqual(fieldsReceived, true); + assert.strictEqual(rows.length >= 2, true); + assert.deepStrictEqual(res.rows, rows); + }); + }); + + describe('Transactions (BEGIN / COMMIT / ROLLBACK)', () => { + it('should insert a singer in a transaction and COMMIT', async () => { + await client.query('BEGIN'); + assert.strictEqual(client.txStatus, 'T'); + + await client.query(` + INSERT INTO Singers (SingerId, FirstName, LastName, Active) + VALUES (3, 'Alice', 'Cooper', true) + `); + + await client.query('COMMIT'); + assert.strictEqual(client.txStatus, 'I'); + + const res = await client.query( + 'SELECT FirstName FROM Singers WHERE SingerId = 3', + ); + assert.strictEqual(res.rowCount, 1); + assert.strictEqual( + res.rows[0].firstname || res.rows[0].FirstName, + 'Alice', + ); + }); + + it('should rollback transaction and not persist rows on ROLLBACK', async () => { + await client.query('BEGIN'); + assert.strictEqual(client.txStatus, 'T'); + + await client.query(` + INSERT INTO Singers (SingerId, FirstName, LastName, Active) + VALUES (4, 'Bob', 'Marley', true) + `); + + await client.query('ROLLBACK'); + assert.strictEqual(client.txStatus, 'I'); + + const res = await client.query( + 'SELECT * FROM Singers WHERE SingerId = 4', + ); + assert.strictEqual(res.rowCount, 0); + }); + + // Currently this test is failing as node wrapper is not returning transaction state in case of error. + it.skip('should transition txStatus to E on error inside transaction and reset to I on ROLLBACK', async () => { + await client.query('BEGIN'); + assert.strictEqual(client.txStatus, 'T'); + + try { + // Trigger an error inside the active transaction + await client.query('SELECT * FROM non_existent_table_for_tx_test'); + assert.fail('Should have thrown error on non-existent table'); + } catch { + assert.strictEqual(client.txStatus, 'E'); + } + + await client.query('ROLLBACK'); + assert.strictEqual(client.txStatus, 'I'); + }); + }); + }); + + describe('Connection Pool (Pool Class)', () => { + it('should acquire client, execute query and release back to pool', async () => { + const clientFromPool = await pool.connect(); + assert.ok(clientFromPool); + + try { + const res = await clientFromPool.query( + 'SELECT SingerId, FirstName FROM Singers WHERE SingerId = 1', + ); + assert.strictEqual(res.rowCount, 1); + assert.strictEqual( + res.rows[0].firstname || res.rows[0].FirstName, + 'Marc', + ); + } finally { + await clientFromPool.release(); + } + }); + + it('should execute direct query via pool.query()', async () => { + const res = await pool.query('SELECT count(*) as total FROM Singers'); + assert.strictEqual(res.rowCount, 1); + assert.ok( + Number(res.rows[0].total) >= 2 || Number(res.rows[0].count) >= 2, + ); + }); + + it('should format pool query results as positional arrays when rowMode is array', async () => { + const res = await pool.query({ + text: 'SELECT SingerId, FirstName FROM Singers WHERE SingerId = 1', + rowMode: 'array', + }); + assert.strictEqual(res.rowCount, 1); + assert.deepStrictEqual(res.rows[0], ['1', 'Marc']); + }); + + it('should execute concurrent queries via pool', async () => { + const queries = [ + pool.query( + 'SELECT SingerId, FirstName FROM Singers WHERE SingerId = 1', + ), + pool.query( + 'SELECT SingerId, FirstName FROM Singers WHERE SingerId = 2', + ), + pool.query( + 'SELECT SingerId, FirstName FROM Singers WHERE SingerId = 3', + ), + ]; + + const results = await Promise.all(queries); + assert.strictEqual(results.length, 3); + assert.strictEqual(results[0].rowCount, 1); + assert.strictEqual(results[1].rowCount, 1); + assert.strictEqual(results[2].rowCount, 1); + }); + }); +}); diff --git a/handwritten/spanner-driver/test/unit/client_test.ts b/handwritten/spanner-driver/test/unit/client_test.ts index ac0e2ec72669..5a7c480a40b4 100644 --- a/handwritten/spanner-driver/test/unit/client_test.ts +++ b/handwritten/spanner-driver/test/unit/client_test.ts @@ -13,392 +13,499 @@ // limitations under the License. import * as assert from 'assert'; -import {describe, it} from 'mocha'; +import {afterEach, beforeEach, describe, it} from 'mocha'; +import * as sinon from 'sinon'; import {Client, DatabaseError, Query, QueryResult} from '../../src/index.js'; +import {Pool as NativePool} from '../../src/lib/native.js'; +import {createMockPool} from './mock_native.js'; describe('Client Class', () => { - it('should instantiate Client with config object or string', () => { - const client1 = new Client({ - project: 'p', - instance: 'i', - database: 'd', - }); - assert.strictEqual(client1.dsn, 'projects/p/instances/i/databases/d'); - - const client2 = new Client('projects/p/instances/i/databases/d'); - assert.strictEqual(client2.dsn, 'projects/p/instances/i/databases/d'); - }); + describe('Unit Tests (Config & Validation)', () => { + it('should instantiate Client with config object or string', () => { + const client1 = new Client({ + project: 'p', + instance: 'i', + database: 'd', + }); + assert.strictEqual(client1.dsn, 'projects/p/instances/i/databases/d'); - it('should connect and close Client', async () => { - const client = new Client({ - project: 'p', - instance: 'i', - database: 'd', + const client2 = new Client('projects/p/instances/i/databases/d'); + assert.strictEqual(client2.dsn, 'projects/p/instances/i/databases/d'); }); - await client.connect(); - assert.strictEqual(client.isConnected, true); - await client.end(); - assert.strictEqual(client.isConnected, false); - }); - it('should connect using callback syntax', done => { - const client = new Client({ - project: 'p', - instance: 'i', - database: 'd', - }); - client.connect(err => { - assert.strictEqual(err, null); - assert.strictEqual(client.isConnected, true); - client.end(() => { - assert.strictEqual(client.isConnected, false); + it('should invoke callback with error when client.connect(cb) fails on invalid config', done => { + const client = new Client({}); + client.connect(err => { + assert.strictEqual(err instanceof DatabaseError, true); + assert.match(err!.message, /Invalid Spanner connection configuration/); done(); }); }); - }); - it('should invoke callback with error when client.connect(cb) fails', done => { - const client = new Client({}); - client.connect(err => { - assert.strictEqual(err instanceof DatabaseError, true); - assert.match(err!.message, /Invalid Spanner connection configuration/); - done(); + it('should reject empty query text with enriched DatabaseError', async () => { + const client = new Client({ + project: 'p', + instance: 'i', + database: 'd', + }); + try { + await client.query(''); + assert.fail('Should have thrown error'); + } catch (err: unknown) { + assert.strictEqual(err instanceof DatabaseError, true); + const dbErr = err as DatabaseError; + assert.strictEqual(dbErr.code, 'XX000'); + } finally { + await client.end(); + } + }); + + it('should reject non-array query values with enriched DatabaseError', async () => { + const client = new Client({ + project: 'p', + instance: 'i', + database: 'd', + }); + try { + // @ts-expect-error Testing runtime invalid values argument + await client.query('SELECT $1', 'not-an-array'); + assert.fail('Should have thrown error'); + } catch (err: unknown) { + assert.strictEqual(err instanceof DatabaseError, true); + const dbErr = err as DatabaseError; + assert.strictEqual(dbErr.code, 'XX000'); + } finally { + await client.end(); + } + }); + + it('should deduplicate concurrent connect() calls and initiate connection exactly once', async () => { + const client = new Client({ + project: 'p', + instance: 'i', + database: 'd', + }); + let connectInvocations = 0; + + (client as unknown as {_doConnect: () => Promise})['_doConnect'] = + async () => { + if (client.isConnected) return; + connectInvocations++; + await new Promise(r => setTimeout(r, 20)); + client.isConnected = true; + }; + + await Promise.all([client.connect(), client.connect(), client.connect()]); + + assert.strictEqual( + connectInvocations, + 1, + 'concurrent connect() calls should only initiate connection once', + ); }); - }); - it('should execute query with async/await and return QueryResult', async () => { - const client = new Client({ - project: 'p', - instance: 'i', - database: 'd', + it('should handle multiple client.end() calls safely without error', async () => { + const client = new Client({ + project: 'p', + instance: 'i', + database: 'd', + }); + await client.end(); + await assert.doesNotReject(async () => client.end()); }); - const res = await client.query('SELECT 1'); - assert.strictEqual(res.command, 'SELECT'); - assert.deepStrictEqual(res.rows, []); - assert.deepStrictEqual(res.fields, []); - await client.end(); - }); - it('should execute query with callback syntax', done => { - const client = new Client({ - project: 'p', - instance: 'i', - database: 'd', - }); - void client.query('SELECT 1', (err, res) => { - assert.strictEqual(err, null); - assert.strictEqual(res?.command, 'SELECT'); - void client.end().then(() => done()); - }); - }); + it('should clear pending query queue when client.end() is called', async () => { + const client = new Client({ + project: 'p', + instance: 'i', + database: 'd', + }); + (client as unknown as {isConnected: boolean}).isConnected = true; - it('should resolve callback when passing Query instance and 3rd argument callback function', done => { - const client = new Client({ - project: 'p', - instance: 'i', - database: 'd', - }); - const q = new Query('SELECT $1', [42]); - void client.query(q, [42], (err, res) => { - assert.strictEqual(err, null); - assert.strictEqual(res?.command, 'SELECT'); - void client.end().then(() => done()); - }); - }); + // Queue query without awaiting + const p1 = client.query('SELECT 1'); + await client.end(); + + // Verify queue was emptied + assert.strictEqual( + (client as unknown as {queryQueue: unknown[]}).queryQueue.length, + 0, + 'query queue should be emptied when client is closed', + ); + try { + await p1; + } catch { + // Expect rejection on ended client + } + }); + + it('should reject pending queries in queryQueue with Client was closed error when client.end() is called', async () => { + const client = new Client({ + project: 'p', + instance: 'i', + database: 'd', + }); + let finishConnect!: () => void; + const connectGate = new Promise(resolve => { + finishConnect = resolve; + }); + (client as unknown as {_doConnect: () => Promise})._doConnect = + async () => { + await connectGate; + (client as unknown as {isConnected: boolean}).isConnected = true; + }; + + const p1 = client.query('SELECT 1'); + const p2 = client.query('SELECT 2'); + const p3 = client.query('SELECT 3'); + + p1.catch(() => {}); - it('should reject empty query text with enriched DatabaseError', async () => { - const client = new Client({ - project: 'p', - instance: 'i', - database: 'd', - }); - try { - await client.query(''); - assert.fail('Should have thrown error'); - } catch (err: unknown) { - assert.strictEqual(err instanceof DatabaseError, true); - const dbErr = err as DatabaseError; - assert.strictEqual(dbErr.code, 'XX000'); - } finally { await client.end(); - } - }); + finishConnect(); - it('should reject non-array query values with enriched DatabaseError', async () => { - const client = new Client({ - project: 'p', - instance: 'i', - database: 'd', + assert.strictEqual( + (client as unknown as {queryQueue: unknown[]}).queryQueue.length, + 0, + ); + await assert.rejects(async () => p2, /Client was closed/); + await assert.rejects(async () => p3, /Client was closed/); }); - try { - // @ts-expect-error Testing runtime invalid values argument - await client.query('SELECT $1', 'not-an-array'); - assert.fail('Should have thrown error'); - } catch (err: unknown) { - assert.strictEqual(err instanceof DatabaseError, true); - const dbErr = err as DatabaseError; - assert.strictEqual(dbErr.code, 'XX000'); - } finally { - await client.end(); - } - }); - it('should invoke callback and NOT emit error event when callback is provided on query error', async () => { - const client = new Client({ - project: 'p', - instance: 'i', - database: 'd', + it('should delegate release() to end()', async () => { + const client = new Client({ + project: 'p', + instance: 'i', + database: 'd', + }); + (client as unknown as {isConnected: boolean}).isConnected = true; + assert.strictEqual(client.isConnected, true); + await client.release(); + assert.strictEqual(client.isConnected, false); }); - let errorEventEmitted = false; - let callbackInvoked = false; - const q = new Query(''); - void q.on('error', () => { - errorEventEmitted = true; + it('should delegate release(cb) to end(cb) using callback syntax', done => { + const client = new Client({ + project: 'p', + instance: 'i', + database: 'd', + }); + (client as unknown as {isConnected: boolean}).isConnected = true; + client.release(err => { + assert.strictEqual(err, null); + assert.strictEqual(client.isConnected, false); + done(); + }); }); - await new Promise(resolve => { - void client.query(q, undefined, err => { + it('should emit error event on Query when client.query() connection fails without callback', done => { + const client = new Client({}); + const q = client.query('SELECT 1'); + void q.on('error', err => { assert.strictEqual(err instanceof DatabaseError, true); - callbackInvoked = true; - setTimeout(resolve, 20); + assert.match(err.message, /Invalid Spanner connection configuration/); + done(); }); + void q.catch(() => {}); }); - assert.strictEqual( - errorEventEmitted, - false, - 'error event should not be emitted when callback is provided', - ); - assert.strictEqual(callbackInvoked, true); - }); + it('should emit error event on validation error even when listener is attached after client.query() returns', async () => { + const client = new Client({ + project: 'p', + instance: 'i', + database: 'd', + }); + let errorEventEmitted = false; + + await new Promise(resolve => { + const q = client.query(''); // empty SQL triggers validation error + void q.on('error', () => { + errorEventEmitted = true; + resolve(); + }); + setTimeout(resolve, 50); + }); - it('should reject queries executed after client.end() without reconnecting', async () => { - const client = new Client({ - project: 'p', - instance: 'i', - database: 'd', - }); - await client.connect(); - assert.strictEqual(client.isConnected, true); - await client.end(); - assert.strictEqual(client.isConnected, false); - - try { - await client.query('SELECT 1'); - assert.fail('Should have thrown an error when querying an ended client'); - } catch (err: unknown) { - assert.strictEqual(client.isConnected, false); - assert.match( - (err as Error).message, - /Client has already been connected|Connection terminated|Client was closed/, + assert.strictEqual( + errorEventEmitted, + true, + 'error event should be emitted even when listener is attached immediately after client.query() returns', ); - } - }); - - it('should deduplicate concurrent connect() calls and initiate connection exactly once', async () => { - const client = new Client({ - project: 'p', - instance: 'i', - database: 'd', }); - let connectInvocations = 0; - - (client as unknown as {_doConnect: () => Promise})['_doConnect'] = - async () => { - if (client.isConnected) return; - connectInvocations++; - await new Promise(r => setTimeout(r, 20)); - client.isConnected = true; - }; - - await Promise.all([client.connect(), client.connect(), client.connect()]); - - assert.strictEqual( - connectInvocations, - 1, - 'concurrent connect() calls should only initiate connection once', - ); }); - it('should handle multiple client.end() calls safely without error', async () => { - const client = new Client({ - project: 'p', - instance: 'i', - database: 'd', - }); - await client.connect(); - await client.end(); - await assert.doesNotReject(async () => client.end()); - }); + describe('Mock Native Bridge Execution (End-to-End Query & State Flow)', () => { + let poolStub: sinon.SinonStub; - it('should clear pending query queue when client.end() is called', async () => { - const client = new Client({ - project: 'p', - instance: 'i', - database: 'd', + beforeEach(() => { + poolStub = sinon + .stub(NativePool, 'create') + .callsFake(async () => createMockPool()); }); - await client.connect(); - - // Queue query without awaiting - const p1 = client.query('SELECT 1'); - await client.end(); - - // Verify queue was emptied - assert.strictEqual( - (client as unknown as {queryQueue: unknown[]}).queryQueue.length, - 0, - 'query queue should be emptied when client is closed', - ); - try { - await p1; - } catch { - // Expect rejection on ended client - } - }); - it('should reject pending queries in queryQueue with Client was closed error when client.end() is called', async () => { - const client = new Client({ - project: 'p', - instance: 'i', - database: 'd', + afterEach(() => { + poolStub.restore(); }); - let finishConnect!: () => void; - const connectGate = new Promise(resolve => { - finishConnect = resolve; - }); - const origDoConnect = ( - client as unknown as {_doConnect: () => Promise} - )._doConnect.bind(client); - (client as unknown as {_doConnect: () => Promise})._doConnect = - async () => { - await connectGate; - return origDoConnect(); - }; - - const p1 = client.query('SELECT 1'); - const p2 = client.query('SELECT 2'); - const p3 = client.query('SELECT 3'); - - p1.catch(() => {}); - - await client.end(); - finishConnect(); - - assert.strictEqual( - (client as unknown as {queryQueue: unknown[]}).queryQueue.length, - 0, - ); - await assert.rejects(async () => p2, /Client was closed/); - await assert.rejects(async () => p3, /Client was closed/); - }); - it('should reject connect() calls on an ended client and handle concurrent connect/end', async () => { - const client = new Client({ - project: 'p', - instance: 'i', - database: 'd', + it('should connect and close Client', async () => { + const client = new Client({ + project: 'p', + instance: 'i', + database: 'd', + }); + await client.connect(); + assert.strictEqual(client.isConnected, true); + // Redundant connect() call should resolve successfully as a no-op matching pg + const reconnected = await client.connect(); + assert.strictEqual(reconnected, client); + assert.strictEqual(client.isConnected, true); + await client.end(); + assert.strictEqual(client.isConnected, false); }); - const connectPromise = client.connect(); - await client.end(); - try { - await connectPromise; - } catch { - // Ignored if race rejected - } - assert.strictEqual(client.isConnected, false); - await assert.rejects(async () => client.connect(), /Client was closed/); - }); - it('should emit end event on Query when query execution completes', done => { - const client = new Client({ - project: 'p', - instance: 'i', - database: 'd', + it('should connect using callback syntax', done => { + const client = new Client({ + project: 'p', + instance: 'i', + database: 'd', + }); + client.connect(err => { + assert.strictEqual(err, null); + assert.strictEqual(client.isConnected, true); + client.end(() => { + assert.strictEqual(client.isConnected, false); + done(); + }); + }); }); - let endEventEmitted = false; - const q = client.query('SELECT 1'); - void q.on('end', res => { - endEventEmitted = true; - assert.strictEqual(res.command, 'SELECT'); - void client.end().then(() => { - assert.strictEqual(endEventEmitted, true); - done(); + + it('should reject connect() calls on an ended client and handle concurrent connect/end', async () => { + const client = new Client({ + project: 'p', + instance: 'i', + database: 'd', }); + const connectPromise = client.connect(); + await client.end(); + try { + await connectPromise; + } catch { + // Ignored if race rejected + } + assert.strictEqual(client.isConnected, false); + await assert.rejects( + async () => client.connect(), + /Client was (already )?closed/, + ); }); - }); - it('should delegate release() to end()', async () => { - const client = new Client({ - project: 'p', - instance: 'i', - database: 'd', + it('should execute query with async/await and return QueryResult', async () => { + const client = new Client({ + project: 'p', + instance: 'i', + database: 'd', + }); + const res = await client.query('SELECT 1'); + assert.strictEqual(res.command, 'SELECT'); + assert.strictEqual(res.rowCount, 1); + assert.deepStrictEqual(res.rows, [{'?column?': '1'}]); + assert.strictEqual(res.fields.length, 1); + await client.end(); }); - await client.connect(); - assert.strictEqual(client.isConnected, true); - await client.release(); - assert.strictEqual(client.isConnected, false); - }); - it('should emit error event on Query when no callback is provided on query error', done => { - const client = new Client({ - project: 'p', - instance: 'i', - database: 'd', + it('should execute query with callback syntax', done => { + const client = new Client({ + project: 'p', + instance: 'i', + database: 'd', + }); + void client.query('SELECT 1', (err, res) => { + assert.strictEqual(err, null); + assert.strictEqual(res?.command, 'SELECT'); + void client.end().then(() => done()); + }); }); - const q = new Query(''); - void q.on('error', err => { - assert.strictEqual(err instanceof DatabaseError, true); - void client.end().then(() => done()); + + it('should resolve callback when passing Query instance and 3rd argument callback function', done => { + const client = new Client({ + project: 'p', + instance: 'i', + database: 'd', + }); + const q = new Query('SELECT $1', [42]); + void client.query(q, [42], (err, res) => { + assert.strictEqual(err, null); + assert.strictEqual(res?.command, 'SELECT'); + void client.end().then(() => done()); + }); }); - void client.query(q).catch(() => {}); - }); - it('should delegate release(cb) to end(cb) using callback syntax', done => { - const client = new Client({ - project: 'p', - instance: 'i', - database: 'd', + it('should invoke callback and NOT emit error event when callback is provided on query error', async () => { + const client = new Client({ + project: 'p', + instance: 'i', + database: 'd', + }); + let errorEventEmitted = false; + let callbackInvoked = false; + + const q = new Query('FAIL_QUERY'); + void q.on('error', () => { + errorEventEmitted = true; + }); + + await new Promise(resolve => { + void client.query(q, undefined, err => { + assert.strictEqual(err instanceof DatabaseError, true); + callbackInvoked = true; + setTimeout(resolve, 20); + }); + }); + + assert.strictEqual( + errorEventEmitted, + false, + 'error event should not be emitted when callback is provided', + ); + assert.strictEqual(callbackInvoked, true); + await client.end(); }); - client.release(err => { - assert.strictEqual(err, null); + + it('should reject queries executed after client.end() without reconnecting', async () => { + const client = new Client({ + project: 'p', + instance: 'i', + database: 'd', + }); + await client.connect(); + assert.strictEqual(client.isConnected, true); + await client.end(); assert.strictEqual(client.isConnected, false); - done(); + + try { + await client.query('SELECT 1'); + assert.fail( + 'Should have thrown an error when querying an ended client', + ); + } catch (err: unknown) { + assert.strictEqual(client.isConnected, false); + assert.match( + (err as Error).message, + /Client has already been connected|Connection terminated|Client was closed/, + ); + } + }); + + it('should emit end event on Query when query execution completes', done => { + const client = new Client({ + project: 'p', + instance: 'i', + database: 'd', + }); + let endEventEmitted = false; + const q = client.query('SELECT 1'); + void q.on('end', res => { + endEventEmitted = true; + assert.strictEqual(res.command, 'SELECT'); + void client.end().then(() => { + assert.strictEqual(endEventEmitted, true); + done(); + }); + }); }); - }); - it('should emit error event on Query when client.query() connection fails without callback', done => { - const client = new Client({}); - const q = client.query('SELECT 1'); - void q.on('error', err => { - assert.strictEqual(err instanceof DatabaseError, true); - assert.match(err.message, /Invalid Spanner connection configuration/); - done(); + it('should emit error event on Query when no callback is provided on query error', done => { + const client = new Client({ + project: 'p', + instance: 'i', + database: 'd', + }); + const q = new Query('FAIL_QUERY'); + void q.on('error', err => { + assert.strictEqual(err instanceof DatabaseError, true); + void client.end().then(() => done()); + }); + void client.query(q).catch(() => {}); }); - void q.catch(() => {}); - }); - it('should emit error event on validation error even when listener is attached after client.query() returns', async () => { - const client = new Client({ - project: 'p', - instance: 'i', - database: 'd', + it('should execute query returning rows and fields metadata via native bridge', async () => { + const client = new Client({ + project: 'p', + instance: 'i', + database: 'd', + }); + const res = await client.query('SELECT 1'); + assert.strictEqual(res.command, 'SELECT'); + assert.strictEqual(res.rowCount, 1); + assert.strictEqual(res.fields.length, 1); + assert.strictEqual(res.fields[0].name, '?column?'); + assert.strictEqual(res.rows.length, 1); + assert.deepStrictEqual(res.rows[0], {'?column?': '1'}); + await client.end(); }); - let errorEventEmitted = false; - await new Promise(resolve => { - const q = client.query(''); // empty SQL triggers validation error - void q.on('error', () => { - errorEventEmitted = true; - resolve(); + it('should stream fields and row events during client.query()', async () => { + const client = new Client({ + project: 'p', + instance: 'i', + database: 'd', + }); + let fieldsEmitted = false; + const receivedRows: unknown[] = []; + + const q = client.query('SELECT 1'); + void q.on('fields', fields => { + fieldsEmitted = true; + assert.strictEqual(fields.length, 1); + }); + void q.on('row', row => { + receivedRows.push(row); + }); + + const res = await q; + assert.strictEqual(fieldsEmitted, true); + assert.strictEqual(receivedRows.length, 1); + assert.deepStrictEqual(res.rows, receivedRows); + await client.end(); + }); + + it('should support parameterized queries with values array', async () => { + const client = new Client({ + project: 'p', + instance: 'i', + database: 'd', }); - setTimeout(resolve, 50); + const res = await client.query('SELECT $1 as name', ['hello']); + assert.strictEqual(res.command, 'SELECT'); + await client.end(); }); - assert.strictEqual( - errorEventEmitted, - true, - 'error event should be emitted even when listener is attached immediately after client.query() returns', - ); + it('should track transaction status transitions (I -> T -> E -> I)', async () => { + const client = new Client({ + project: 'p', + instance: 'i', + database: 'd', + }); + await client.connect(); + assert.strictEqual(client.txStatus, 'I'); + + // 1. BEGIN transaction -> 'T' + await client.query('BEGIN'); + assert.strictEqual(client.txStatus, 'T'); + + // 2. Query failure inside transaction -> 'E' + try { + await client.query('FAIL_QUERY'); + assert.fail('Should have failed'); + } catch { + assert.strictEqual(client.txStatus, 'E'); + } + + // 3. ROLLBACK aborted transaction -> 'I' + await client.query('ROLLBACK'); + assert.strictEqual(client.txStatus, 'I'); + + await client.end(); + }); }); }); diff --git a/handwritten/spanner-driver/test/unit/codec_test.ts b/handwritten/spanner-driver/test/unit/codec_test.ts new file mode 100644 index 000000000000..1add534a0154 --- /dev/null +++ b/handwritten/spanner-driver/test/unit/codec_test.ts @@ -0,0 +1,219 @@ +// 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 {Codec} from '../../src/lib/codec.js'; +import {BuiltinOids} from '../../src/lib/pg/types.js'; +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore +import pkg from '@google-cloud/spanner/build/protos/protos.js'; +import type {google as GoogleProto} from '@google-cloud/spanner/build/protos/protos.js'; + +const {google} = pkg as {google: typeof GoogleProto}; + +describe('Codec Utilities', () => { + describe('mapMetadataToFieldDefs', () => { + it('should return empty array for null/undefined metadata', () => { + assert.deepStrictEqual(Codec.mapMetadataToFieldDefs(null), []); + assert.deepStrictEqual(Codec.mapMetadataToFieldDefs(undefined), []); + assert.deepStrictEqual(Codec.mapMetadataToFieldDefs({}), []); + }); + + it('should map Spanner scalar and array TypeCodes to PostgreSQL OIDs', () => { + const metadata: GoogleProto.spanner.v1.IResultSetMetadata = { + rowType: { + fields: [ + {name: 'b', type: {code: google.spanner.v1.TypeCode.BOOL}}, + {name: 'i', type: {code: google.spanner.v1.TypeCode.INT64}}, + {name: 'f', type: {code: google.spanner.v1.TypeCode.FLOAT64}}, + {name: 'ts', type: {code: google.spanner.v1.TypeCode.TIMESTAMP}}, + {name: 'd', type: {code: google.spanner.v1.TypeCode.DATE}}, + {name: 's', type: {code: google.spanner.v1.TypeCode.STRING}}, + {name: 'by', type: {code: google.spanner.v1.TypeCode.BYTES}}, + {name: 'n', type: {code: google.spanner.v1.TypeCode.NUMERIC}}, + {name: 'j', type: {code: google.spanner.v1.TypeCode.JSON}}, + { + name: 'arr_i', + type: { + code: google.spanner.v1.TypeCode.ARRAY, + arrayElementType: {code: google.spanner.v1.TypeCode.INT64}, + }, + }, + { + name: 'arr_s', + type: { + code: google.spanner.v1.TypeCode.ARRAY, + arrayElementType: {code: google.spanner.v1.TypeCode.STRING}, + }, + }, + ], + }, + }; + + const fields = Codec.mapMetadataToFieldDefs(metadata, 'pg'); + assert.strictEqual(fields.length, 11); + assert.strictEqual(fields[0].dataTypeID, BuiltinOids.BOOL); + assert.strictEqual(fields[1].dataTypeID, BuiltinOids.INT8); + assert.strictEqual(fields[2].dataTypeID, BuiltinOids.FLOAT8); + assert.strictEqual(fields[3].dataTypeID, BuiltinOids.TIMESTAMPTZ); + assert.strictEqual(fields[4].dataTypeID, BuiltinOids.DATE); + assert.strictEqual(fields[5].dataTypeID, BuiltinOids.TEXT); + assert.strictEqual(fields[6].dataTypeID, BuiltinOids.BYTEA); + assert.strictEqual(fields[7].dataTypeID, BuiltinOids.NUMERIC); + assert.strictEqual(fields[9].dataTypeID, 1016); // int8[] + assert.strictEqual(fields[10].dataTypeID, 1009); // text[] + }); + + it('should map GoogleSQL dialect types directly as strings', () => { + const metadata: GoogleProto.spanner.v1.IResultSetMetadata = { + rowType: { + fields: [{name: 'i', type: {code: google.spanner.v1.TypeCode.INT64}}], + }, + }; + const fields = Codec.mapMetadataToFieldDefs(metadata, 'googlesql'); + assert.strictEqual(fields.length, 1); + assert.strictEqual( + fields[0].dataTypeID, + String(google.spanner.v1.TypeCode.INT64), + ); + }); + }); + + describe('extractRawRow', () => { + it('should return empty array for null/undefined ListValue', () => { + assert.deepStrictEqual(Codec.extractRawRow(null), []); + assert.deepStrictEqual(Codec.extractRawRow(undefined), []); + assert.deepStrictEqual(Codec.extractRawRow({}), []); + }); + + it('should extract string wire representations correctly', () => { + const listValue: GoogleProto.protobuf.IListValue = { + values: [ + {stringValue: 'hello'}, + {stringValue: '123'}, + {boolValue: true}, + {boolValue: false}, + {numberValue: 45.6}, + {nullValue: google.protobuf.NullValue.NULL_VALUE}, + {structValue: {fields: {k: {stringValue: 'v'}}}}, + ], + }; + + const raw = Codec.extractRawRow(listValue); + assert.strictEqual(raw[0], 'hello'); + assert.strictEqual(raw[1], '123'); + assert.strictEqual(raw[2], 't'); + assert.strictEqual(raw[3], 'f'); + assert.strictEqual(raw[4], '45.6'); + assert.strictEqual(raw[5], null); + assert.strictEqual(typeof raw[6], 'string'); + }); + }); + + describe('encodeValue & encodeParams', () => { + it('should encode JavaScript primitives and complex objects into Spanner protobuf format', () => { + // Booleans + assert.deepStrictEqual(Codec.encodeValue(true), { + valueProto: {boolValue: true}, + typeProto: {code: google.spanner.v1.TypeCode.BOOL}, + }); + + // Integers + assert.deepStrictEqual(Codec.encodeValue(42), { + valueProto: {stringValue: '42'}, + typeProto: {code: google.spanner.v1.TypeCode.INT64}, + }); + + // Floats + assert.deepStrictEqual(Codec.encodeValue(3.14), { + valueProto: {numberValue: 3.14}, + typeProto: {code: google.spanner.v1.TypeCode.FLOAT64}, + }); + + // BigInt + assert.deepStrictEqual(Codec.encodeValue(BigInt(9007199254740991)), { + valueProto: {stringValue: '9007199254740991'}, + typeProto: {code: google.spanner.v1.TypeCode.INT64}, + }); + + // Buffer + const buf = Buffer.from('hello'); + assert.deepStrictEqual(Codec.encodeValue(buf), { + valueProto: {stringValue: buf.toString('base64')}, + typeProto: {code: google.spanner.v1.TypeCode.BYTES}, + }); + + // Dates + const d = new Date('2023-01-01T00:00:00.000Z'); + assert.deepStrictEqual(Codec.encodeValue(d), { + valueProto: {stringValue: d.toISOString()}, + typeProto: {code: google.spanner.v1.TypeCode.TIMESTAMP}, + }); + + // Invalid Date object -> nullValue + const invalidDate = new Date('invalid'); + assert.deepStrictEqual(Codec.encodeValue(invalidDate), { + valueProto: {nullValue: google.protobuf.NullValue.NULL_VALUE}, + typeProto: {code: google.spanner.v1.TypeCode.TIMESTAMP}, + }); + + // Objects / JSON + const obj = {genre: 'rock'}; + assert.deepStrictEqual(Codec.encodeValue(obj), { + valueProto: {stringValue: JSON.stringify(obj)}, + typeProto: {code: google.spanner.v1.TypeCode.STRING}, + }); + + // Null / Undefined + assert.deepStrictEqual(Codec.encodeValue(null), { + valueProto: {nullValue: google.protobuf.NullValue.NULL_VALUE}, + typeProto: {code: google.spanner.v1.TypeCode.TYPE_CODE_UNSPECIFIED}, + }); + }); + + it('should encode arrays correctly', () => { + // Empty array + const emptyArr = Codec.encodeValue([]); + assert.deepStrictEqual(emptyArr.valueProto, {listValue: {values: []}}); + assert.deepStrictEqual(emptyArr.typeProto, { + code: google.spanner.v1.TypeCode.ARRAY, + arrayElementType: { + code: google.spanner.v1.TypeCode.TYPE_CODE_UNSPECIFIED, + }, + }); + + // Integer array + const intArr = Codec.encodeValue([1, 2, 3]); + assert.deepStrictEqual(intArr.valueProto, { + listValue: { + values: [{stringValue: '1'}, {stringValue: '2'}, {stringValue: '3'}], + }, + }); + assert.deepStrictEqual(intArr.typeProto, { + code: google.spanner.v1.TypeCode.ARRAY, + arrayElementType: {code: google.spanner.v1.TypeCode.INT64}, + }); + }); + + it('should encode parameters via Codec.encodeParams supporting toPostgres custom objects', () => { + const customParam = {toPostgres: () => 'custom_val'}; + const {fields} = Codec.encodeParams(['test', 123, true, customParam]); + assert.deepStrictEqual(fields.p1, {stringValue: 'test'}); + assert.deepStrictEqual(fields.p2, {stringValue: '123'}); + assert.deepStrictEqual(fields.p3, {boolValue: true}); + assert.deepStrictEqual(fields.p4, {stringValue: 'custom_val'}); + }); + }); +}); diff --git a/handwritten/spanner-driver/test/unit/mock_native.ts b/handwritten/spanner-driver/test/unit/mock_native.ts new file mode 100644 index 000000000000..9b75d7330737 --- /dev/null +++ b/handwritten/spanner-driver/test/unit/mock_native.ts @@ -0,0 +1,177 @@ +// 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 type {Pool} from '../../src/lib/native.js'; +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore +import pkg from '@google-cloud/spanner/build/protos/protos.js'; +import type {google as GoogleProto} from '@google-cloud/spanner/build/protos/protos.js'; + +const {google} = pkg as {google: typeof GoogleProto}; + +export class MockNativeRows { + public oid = 1; + public closed = false; + private rowIndex = 0; + + constructor( + private readonly rowData: GoogleProto.protobuf.IListValue[] = [], + private readonly metaData: GoogleProto.spanner.v1.IResultSetMetadata | null = null, + private readonly rowUpdateCount = -1, + ) {} + + async next(): Promise { + if (this.closed) throw new Error('Rows are closed'); + if (this.rowIndex >= this.rowData.length) { + return null; + } + const row = this.rowData[this.rowIndex]; + this.rowIndex++; + return row; + } + + async metadata(): Promise { + if (this.closed) throw new Error('Rows are closed'); + return this.metaData; + } + + async updateCount(): Promise { + if (this.closed) throw new Error('Rows are closed'); + return this.rowUpdateCount; + } + + async resultSetStats(): Promise { + if (this.closed) throw new Error('Rows are closed'); + if (this.rowUpdateCount >= 0) { + return { + rowCountExact: this.rowUpdateCount, + }; + } + return null; + } + + async nextResultSet(): Promise { + if (this.closed) throw new Error('Rows are closed'); + return false; + } + + async close(): Promise { + this.closed = true; + } +} + +export class MockNativeConnection { + public oid: number | null = 1; + public closed = false; + public transactionState: 'I' | 'T' | 'E' = 'I'; + + async execute( + request: string | GoogleProto.spanner.v1.IExecuteSqlRequest, + ): Promise { + if (this.closed) throw new Error('Connection is closed'); + + const sql = ( + typeof request === 'string' ? request : request.sql || '' + ).trim(); + const upper = sql.toUpperCase(); + + if (upper.startsWith('BEGIN') || upper.startsWith('START TRANSACTION')) { + this.transactionState = 'T'; + return new MockNativeRows([], null, -1); + } + + if (upper.startsWith('COMMIT')) { + this.transactionState = 'I'; + return new MockNativeRows([], null, -1); + } + + if (upper.startsWith('ROLLBACK')) { + this.transactionState = 'I'; + return new MockNativeRows([], null, -1); + } + + if (upper.includes('FAIL_QUERY') || upper.includes('NON_EXISTENT_TABLE')) { + if (this.transactionState === 'T') { + this.transactionState = 'E'; + } + throw new Error('Query execution failed'); + } + + if (upper.startsWith('SELECT 1')) { + return new MockNativeRows( + [ + { + values: [{stringValue: '1'}], + }, + ], + { + rowType: { + fields: [ + { + name: '?column?', + type: {code: google.spanner.v1.TypeCode.INT64}, + }, + ], + }, + }, + ); + } + + return new MockNativeRows([], null, -1); + } + + async beginTransaction(): Promise { + if (this.closed) throw new Error('Connection is closed'); + this.transactionState = 'T'; + } + + async commit(): Promise { + if (this.closed) throw new Error('Connection is closed'); + this.transactionState = 'I'; + return { + commitTimestamp: {seconds: 1723460000, nanos: 0}, + }; + } + + async rollback(): Promise { + if (this.closed) throw new Error('Connection is closed'); + this.transactionState = 'I'; + } + + async close(): Promise { + this.closed = true; + this.oid = null; + this.transactionState = 'I'; + } +} + +export class MockNativePool { + public oid: number | null = 1; + public closed = false; + public activeConnection: MockNativeConnection = new MockNativeConnection(); + + async createConnection(): Promise { + if (this.closed) throw new Error('Pool is closed'); + return this.activeConnection; + } + + async close(): Promise { + this.closed = true; + this.oid = null; + } +} + +export function createMockPool(): Pool { + return new MockNativePool() as unknown as Pool; +} diff --git a/handwritten/spanner-driver/test/unit/pool_test.ts b/handwritten/spanner-driver/test/unit/pool_test.ts index f3e66b56a5c3..c1fa72431201 100644 --- a/handwritten/spanner-driver/test/unit/pool_test.ts +++ b/handwritten/spanner-driver/test/unit/pool_test.ts @@ -13,986 +13,1081 @@ // limitations under the License. import * as assert from 'assert'; -import {describe, it} from 'mocha'; +import {afterEach, beforeEach, describe, it} from 'mocha'; +import * as sinon from 'sinon'; import {Client, Pool, Query, QueryResult} from '../../src/index.js'; +import {Pool as NativePool} from '../../src/lib/native.js'; +import {createMockPool} from './mock_native.js'; describe('Pool Class', () => { - it('should instantiate Pool with config object or connection string and resolve dsn', () => { - const pool1 = new Pool({ - project: 'p', - instance: 'i', - database: 'd', - }); - assert.strictEqual(pool1.config.project, 'p'); - assert.strictEqual(pool1.dsn, 'projects/p/instances/i/databases/d'); - - const pool2 = new Pool('projects/p/instances/i/databases/d'); - assert.strictEqual( - pool2.config.connectionString, - 'projects/p/instances/i/databases/d', - ); - assert.strictEqual(pool2.dsn, 'projects/p/instances/i/databases/d'); - }); - - it('should acquire client via connect() promise and return it to idle pool on release()', async () => { - const pool = new Pool({ - project: 'p', - instance: 'i', - database: 'd', - }); - const client = await pool.connect(); - assert.strictEqual(client.isConnected, true); - assert.strictEqual(typeof client.release, 'function'); - assert.strictEqual(pool.totalCount, 1); - assert.strictEqual(pool.idleCount, 0); - - await client.release(); - assert.strictEqual(client.isConnected, true, 'Client remains connected in idle pool'); - assert.strictEqual(pool.idleCount, 1); - assert.strictEqual(pool.totalCount, 1); - - await pool.end(); - assert.strictEqual(pool.idleCount, 0); - assert.strictEqual(pool.totalCount, 0); - assert.strictEqual(client.isConnected, false, 'Client is closed when pool ends'); - }); + describe('Unit Tests (Config & Validation)', () => { + it('should instantiate Pool with config object or connection string and resolve dsn', () => { + const pool1 = new Pool({ + project: 'p', + instance: 'i', + database: 'd', + }); + assert.strictEqual(pool1.config.project, 'p'); + assert.strictEqual(pool1.dsn, 'projects/p/instances/i/databases/d'); - it('should reuse idle clients from the pool on subsequent connect() calls', async () => { - const pool = new Pool({ - project: 'p', - instance: 'i', - database: 'd', + const pool2 = new Pool('projects/p/instances/i/databases/d'); + assert.strictEqual( + pool2.config.connectionString, + 'projects/p/instances/i/databases/d', + ); + assert.strictEqual(pool2.dsn, 'projects/p/instances/i/databases/d'); }); - const client1 = await pool.connect(); - await client1.release(); - - const client2 = await pool.connect(); - assert.strictEqual(client1, client2, 'Should reuse the same client instance'); - await client2.release(); - await pool.end(); }); - it('should respect max pool limit and queue pending acquisitions', async () => { - const pool = new Pool({ - project: 'p', - instance: 'i', - database: 'd', - max: 2, - }); + describe('Mock Native Bridge Execution (End-to-End Pooling & Lifecycle)', () => { + let poolStub: sinon.SinonStub; - const c1 = await pool.connect(); - const c2 = await pool.connect(); - assert.strictEqual(pool.totalCount, 2); - assert.strictEqual(pool.idleCount, 0); + beforeEach(() => { + poolStub = sinon + .stub(NativePool, 'create') + .callsFake(async () => createMockPool()); + }); - let c3Acquired = false; - let c3Client: Client | undefined; - const p3 = pool.connect().then(c => { - c3Acquired = true; - c3Client = c; - return c; + afterEach(() => { + poolStub.restore(); }); - assert.strictEqual(pool.waitingCount, 1); - assert.strictEqual(c3Acquired, false); + it('should acquire client via connect() promise and return it to idle pool on release()', async () => { + const pool = new Pool({ + project: 'p', + instance: 'i', + database: 'd', + }); + const client = await pool.connect(); + assert.strictEqual(client.isConnected, true); + assert.strictEqual(typeof client.release, 'function'); + assert.strictEqual(pool.totalCount, 1); + assert.strictEqual(pool.idleCount, 0); - await c1.release(); - await p3; + await client.release(); + assert.strictEqual( + client.isConnected, + true, + 'Client remains connected in idle pool', + ); + assert.strictEqual(pool.idleCount, 1); + assert.strictEqual(pool.totalCount, 1); - assert.strictEqual(c3Acquired, true); - assert.strictEqual(c3Client, c1, 'Queued acquirer should receive released client'); - assert.strictEqual(pool.waitingCount, 0); + await pool.end(); + assert.strictEqual(pool.idleCount, 0); + assert.strictEqual(pool.totalCount, 0); + assert.strictEqual( + client.isConnected, + false, + 'Client is closed when pool ends', + ); + }); - await c2.release(); - await c3Client!.release(); - await pool.end(); - }); + it('should reuse idle clients from the pool on subsequent connect() calls', async () => { + const pool = new Pool({ + project: 'p', + instance: 'i', + database: 'd', + }); + const client1 = await pool.connect(); + await client1.release(); - it('should reject connection acquisition on connectionTimeoutMillis timeout', async () => { - const pool = new Pool({ - project: 'p', - instance: 'i', - database: 'd', - max: 1, - connectionTimeoutMillis: 50, + const client2 = await pool.connect(); + assert.strictEqual( + client1, + client2, + 'Should reuse the same client instance', + ); + await client2.release(); + await pool.end(); }); - const c1 = await pool.connect(); - assert.strictEqual(pool.totalCount, 1); + it('should respect max pool limit and queue pending acquisitions', async () => { + const pool = new Pool({ + project: 'p', + instance: 'i', + database: 'd', + max: 2, + }); - try { - await pool.connect(); - assert.fail('Should have timed out waiting for connection'); - } catch (err: unknown) { - assert.strictEqual((err as Error).message, 'timeout exceeded when trying to connect'); - } + const c1 = await pool.connect(); + const c2 = await pool.connect(); + assert.strictEqual(pool.totalCount, 2); + assert.strictEqual(pool.idleCount, 0); - await c1.release(); - await pool.end(); - }); + let c3Acquired = false; + let c3Client: Client | undefined; + const p3 = pool.connect().then(c => { + c3Acquired = true; + c3Client = c; + return c; + }); - it('should timeout when client.connect() takes longer than connectionTimeoutMillis', async () => { - const pool = new Pool({ - project: 'p', - instance: 'i', - database: 'd', - connectionTimeoutMillis: 40, - }); - - const origConnect = Client.prototype.connect; - Client.prototype.connect = function () { - return new Promise(r => setTimeout(r, 100)); - }; - - try { - await pool.connect(); - assert.fail('Should have timed out establishing connection'); - } catch (err: unknown) { - assert.strictEqual((err as Error).message, 'timeout exceeded when trying to connect'); - } finally { - Client.prototype.connect = origConnect; - } - - await pool.end(); - }); + assert.strictEqual(pool.waitingCount, 1); + assert.strictEqual(c3Acquired, false); + + await c1.release(); + await p3; - it('should apply connectionTimeoutMillis to onConnect initialization hook', async () => { - const pool = new Pool({ - project: 'p', - instance: 'i', - database: 'd', - connectionTimeoutMillis: 40, - onConnect: async () => { - // Simulating slow onConnect hook taking 100ms - await new Promise(r => setTimeout(r, 100)); - }, - }); - - try { - await pool.connect(); - assert.fail('Should have timed out during onConnect'); - } catch (err: unknown) { + assert.strictEqual(c3Acquired, true); assert.strictEqual( - (err as Error).message, - 'timeout exceeded when trying to connect', + c3Client, + c1, + 'Queued acquirer should receive released client', ); - } + assert.strictEqual(pool.waitingCount, 0); - assert.strictEqual(pool.totalCount, 0); - await pool.end(); - }); - - it('should remove idle client after idleTimeoutMillis expires', async () => { - const pool = new Pool({ - project: 'p', - instance: 'i', - database: 'd', - idleTimeoutMillis: 50, + await c2.release(); + await c3Client!.release(); + await pool.end(); }); - const c1 = await pool.connect(); - await c1.release(); - assert.strictEqual(pool.idleCount, 1); + it('should reject connection acquisition on connectionTimeoutMillis timeout', async () => { + const pool = new Pool({ + project: 'p', + instance: 'i', + database: 'd', + max: 1, + connectionTimeoutMillis: 50, + }); - await new Promise(r => setTimeout(r, 80)); - assert.strictEqual(pool.idleCount, 0); - assert.strictEqual(pool.totalCount, 0); - assert.strictEqual(c1.isConnected, false); + const c1 = await pool.connect(); + assert.strictEqual(pool.totalCount, 1); - await pool.end(); - }); + try { + await pool.connect(); + assert.fail('Should have timed out waiting for connection'); + } catch (err: unknown) { + assert.strictEqual( + (err as Error).message, + 'timeout exceeded when trying to connect', + ); + } - it('should maintain min idle clients even after idleTimeoutMillis expires', async () => { - const pool = new Pool({ - project: 'p', - instance: 'i', - database: 'd', - min: 1, - idleTimeoutMillis: 40, + await c1.release(); + await pool.end(); }); - const c1 = await pool.connect(); - await c1.release(); - assert.strictEqual(pool.idleCount, 1); + it('should timeout when client.connect() takes longer than connectionTimeoutMillis', async () => { + const pool = new Pool({ + project: 'p', + instance: 'i', + database: 'd', + connectionTimeoutMillis: 40, + }); - await new Promise(r => setTimeout(r, 70)); - assert.strictEqual(pool.idleCount, 1, 'min idle client should be retained'); - assert.strictEqual(pool.totalCount, 1); + const origConnect = Client.prototype.connect; + Client.prototype.connect = function () { + return new Promise(r => setTimeout(() => r(this), 100)); + }; - await pool.end(); - }); + try { + await pool.connect(); + assert.fail('Should have timed out establishing connection'); + } catch (err: unknown) { + assert.strictEqual( + (err as Error).message, + 'timeout exceeded when trying to connect', + ); + } finally { + Client.prototype.connect = origConnect; + } - it('should emit pool lifecycle events (connect, acquire, release, remove)', async () => { - const pool = new Pool({ - project: 'p', - instance: 'i', - database: 'd', + await pool.end(); }); - const events: string[] = []; - pool.on('connect', () => events.push('connect')); - pool.on('acquire', () => events.push('acquire')); - pool.on('release', () => events.push('release')); - pool.on('remove', () => events.push('remove')); - - const c = await pool.connect(); - await c.release(); - await pool.end(); + it('should apply connectionTimeoutMillis to onConnect initialization hook', async () => { + const pool = new Pool({ + project: 'p', + instance: 'i', + database: 'd', + connectionTimeoutMillis: 40, + onConnect: async () => { + // Simulating slow onConnect hook taking 100ms + await new Promise(r => setTimeout(r, 100)); + }, + }); - assert.deepStrictEqual(events, ['connect', 'acquire', 'release', 'remove']); - }); + try { + await pool.connect(); + assert.fail('Should have timed out during onConnect'); + } catch (err: unknown) { + assert.strictEqual( + (err as Error).message, + 'timeout exceeded when trying to connect', + ); + } - it('should destroy client when released with error parameter', async () => { - const pool = new Pool({ - project: 'p', - instance: 'i', - database: 'd', + assert.strictEqual(pool.totalCount, 0); + await pool.end(); }); - const c = await pool.connect(); - assert.strictEqual(pool.totalCount, 1); + it('should remove idle client after idleTimeoutMillis expires', async () => { + const pool = new Pool({ + project: 'p', + instance: 'i', + database: 'd', + idleTimeoutMillis: 50, + }); - await c.release(new Error('Fatal error')); - assert.strictEqual(pool.idleCount, 0); - assert.strictEqual(pool.totalCount, 0); - assert.strictEqual(c.isConnected, false); + const c1 = await pool.connect(); + await c1.release(); + assert.strictEqual(pool.idleCount, 1); - await pool.end(); - }); + await new Promise(r => setTimeout(r, 80)); + assert.strictEqual(pool.idleCount, 0); + assert.strictEqual(pool.totalCount, 0); + assert.strictEqual(c1.isConnected, false); - it('should create a fresh replacement client for queued waiter when active client is removed with error', async () => { - const pool = new Pool({ - project: 'p', - instance: 'i', - database: 'd', - max: 1, + await pool.end(); }); - const c1 = await pool.connect(); - assert.strictEqual(pool.totalCount, 1); + it('should maintain min idle clients even after idleTimeoutMillis expires', async () => { + const pool = new Pool({ + project: 'p', + instance: 'i', + database: 'd', + min: 1, + idleTimeoutMillis: 40, + }); + + const c1 = await pool.connect(); + await c1.release(); + assert.strictEqual(pool.idleCount, 1); + + await new Promise(r => setTimeout(r, 70)); + assert.strictEqual( + pool.idleCount, + 1, + 'min idle client should be retained', + ); + assert.strictEqual(pool.totalCount, 1); - let waiterResolved = false; - let newClient: Client | undefined; - const p2 = pool.connect().then(c => { - waiterResolved = true; - newClient = c; - return c; + await pool.end(); }); - assert.strictEqual(pool.waitingCount, 1); + it('should emit pool lifecycle events (connect, acquire, release, remove)', async () => { + const pool = new Pool({ + project: 'p', + instance: 'i', + database: 'd', + }); - // Release c1 with fatal error -> removeClient destroys c1 and connects fresh replacement for waiter - await c1.release(new Error('Connection lost')); - await p2; + const events: string[] = []; + pool.on('connect', () => events.push('connect')); + pool.on('acquire', () => events.push('acquire')); + pool.on('release', () => events.push('release')); + pool.on('remove', () => events.push('remove')); + + const c = await pool.connect(); + await c.release(); + await pool.end(); + + assert.deepStrictEqual(events, [ + 'connect', + 'acquire', + 'release', + 'remove', + ]); + }); - assert.strictEqual(waiterResolved, true); - assert.notStrictEqual(newClient, c1, 'Should instantiate a fresh new Client instance'); - assert.strictEqual(newClient?.isConnected, true); - assert.strictEqual(pool.waitingCount, 0); - assert.strictEqual(pool.totalCount, 1); + it('should destroy client when released with error parameter', async () => { + const pool = new Pool({ + project: 'p', + instance: 'i', + database: 'd', + }); - await newClient!.release(); - await pool.end(); - }); + const c = await pool.connect(); + assert.strictEqual(pool.totalCount, 1); - it('should emit error event on pool when background client emits error and listener is attached', async () => { - const pool = new Pool({ - project: 'p', - instance: 'i', - database: 'd', - }); + await c.release(new Error('Fatal error')); + assert.strictEqual(pool.idleCount, 0); + assert.strictEqual(pool.totalCount, 0); + assert.strictEqual(c.isConnected, false); - let receivedErr: Error | null = null; - pool.on('error', err => { - receivedErr = err; + await pool.end(); }); - const c = await pool.connect(); - c.emit('error', new Error('Background connection dropped')); + it('should create a fresh replacement client for queued waiter when active client is removed with error', async () => { + const pool = new Pool({ + project: 'p', + instance: 'i', + database: 'd', + max: 1, + }); - assert.ok(receivedErr); - assert.strictEqual((receivedErr as Error).message, 'Background connection dropped'); - assert.strictEqual(pool.totalCount, 0, 'Dead client should be removed from pool'); + const c1 = await pool.connect(); + assert.strictEqual(pool.totalCount, 1); - await pool.end(); - }); + let waiterResolved = false; + let newClient: Client | undefined; + const p2 = pool.connect().then(c => { + waiterResolved = true; + newClient = c; + return c; + }); - it('should safely handle background client error when no pool error listener is attached without crashing', async () => { - const pool = new Pool({ - project: 'p', - instance: 'i', - database: 'd', - }); + assert.strictEqual(pool.waitingCount, 1); - const c = await pool.connect(); - // Should not throw or crash uncaught exception and should remove dead client - c.emit('error', new Error('Background silent drop')); - assert.strictEqual(pool.totalCount, 0, 'Dead client should be removed from pool'); + // Release c1 with fatal error -> removeClient destroys c1 and connects fresh replacement for waiter + await c1.release(new Error('Connection lost')); + await p2; - await pool.end(); - }); + assert.strictEqual(waiterResolved, true); + assert.notStrictEqual( + newClient, + c1, + 'Should instantiate a fresh new Client instance', + ); + assert.strictEqual(newClient?.isConnected, true); + assert.strictEqual(pool.waitingCount, 0); + assert.strictEqual(pool.totalCount, 1); - it('should ignore duplicate client.release() calls on the same checkout', async () => { - const pool = new Pool({ - project: 'p', - instance: 'i', - database: 'd', + await newClient!.release(); + await pool.end(); }); - const c = await pool.connect(); - assert.strictEqual(pool.totalCount, 1); - assert.strictEqual(pool.idleCount, 0); + it('should emit error event on pool when background client emits error and listener is attached', async () => { + const pool = new Pool({ + project: 'p', + instance: 'i', + database: 'd', + }); - await c.release(); - assert.strictEqual(pool.idleCount, 1); + let receivedErr: Error | null = null; + pool.on('error', err => { + receivedErr = err; + }); - // Second and third release calls should safely no-op - await c.release(); - await c.release(); - assert.strictEqual(pool.idleCount, 1, 'idleCount must not duplicate client'); - assert.strictEqual(pool.totalCount, 1); + const c = await pool.connect(); + c.emit('error', new Error('Background connection dropped')); - await pool.end(); - }); + assert.ok(receivedErr); + assert.strictEqual( + (receivedErr as Error).message, + 'Background connection dropped', + ); + assert.strictEqual( + pool.totalCount, + 0, + 'Dead client should be removed from pool', + ); - it('should support allowExitOnIdle configuration', async () => { - const pool = new Pool({ - project: 'p', - instance: 'i', - database: 'd', - idleTimeoutMillis: 1000, - allowExitOnIdle: true, + await pool.end(); }); - const c = await pool.connect(); - await c.release(); - assert.strictEqual(pool.idleCount, 1); + it('should safely handle background client error when no pool error listener is attached without crashing', async () => { + const pool = new Pool({ + project: 'p', + instance: 'i', + database: 'd', + }); - await pool.end(); - }); + const c = await pool.connect(); + // Should not throw or crash uncaught exception and should remove dead client + c.emit('error', new Error('Background silent drop')); + assert.strictEqual( + pool.totalCount, + 0, + 'Dead client should be removed from pool', + ); - it('should destroy client after reaching maxUses limit', async () => { - const pool = new Pool({ - project: 'p', - instance: 'i', - database: 'd', - maxUses: 2, + await pool.end(); }); - const c1 = await pool.connect(); - await c1.release(); - assert.strictEqual(pool.idleCount, 1); + it('should ignore duplicate client.release() calls on the same checkout', async () => { + const pool = new Pool({ + project: 'p', + instance: 'i', + database: 'd', + }); - const c1Again = await pool.connect(); - assert.strictEqual(c1, c1Again); + const c = await pool.connect(); + assert.strictEqual(pool.totalCount, 1); + assert.strictEqual(pool.idleCount, 0); - await c1Again.release(); - assert.strictEqual(pool.idleCount, 0, 'Client should be destroyed after 2 uses'); - assert.strictEqual(pool.totalCount, 0); - assert.strictEqual(c1.isConnected, false); + await c.release(); + assert.strictEqual(pool.idleCount, 1); - await pool.end(); - }); + // Second and third release calls should safely no-op + await c.release(); + await c.release(); + assert.strictEqual( + pool.idleCount, + 1, + 'idleCount must not duplicate client', + ); + assert.strictEqual(pool.totalCount, 1); - it('should destroy client after maxLifetimeSeconds expires', async () => { - const pool = new Pool({ - project: 'p', - instance: 'i', - database: 'd', - maxLifetimeSeconds: 0.05, // 50ms + await pool.end(); }); - const c1 = await pool.connect(); - await new Promise(r => setTimeout(r, 60)); - await c1.release(); + it('should support allowExitOnIdle configuration', async () => { + const pool = new Pool({ + project: 'p', + instance: 'i', + database: 'd', + idleTimeoutMillis: 1000, + allowExitOnIdle: true, + }); - assert.strictEqual(pool.idleCount, 0, 'Client should be destroyed due to maxLifetimeSeconds'); - assert.strictEqual(pool.totalCount, 0); - assert.strictEqual(c1.isConnected, false); + const c = await pool.connect(); + await c.release(); + assert.strictEqual(pool.idleCount, 1); - await pool.end(); - }); + await pool.end(); + }); - it('should evict expired idle client when connect() is called after maxLifetimeSeconds', async () => { - const pool = new Pool({ - project: 'p', - instance: 'i', - database: 'd', - maxLifetimeSeconds: 0.05, // 50ms - idleTimeoutMillis: 0, // do not evict on idle timeout - }); - - const c1 = await pool.connect(); - // Released immediately while still young - await c1.release(); - assert.strictEqual(pool.idleCount, 1); - assert.strictEqual(pool.totalCount, 1); - - // Wait 60ms so client expires while sitting idle in pool - await new Promise(r => setTimeout(r, 60)); - - // Connect again -> should detect expired lifetime on checkout, evict c1, and create fresh c2 - const c2 = await pool.connect(); - assert.notStrictEqual(c1, c2, 'Should create a fresh client rather than reusing expired idle client'); - assert.strictEqual(c1.isConnected, false, 'Expired client should have been closed'); - assert.strictEqual(c2.isConnected, true); - assert.strictEqual(pool.totalCount, 1); - - await c2.release(); - await pool.end(); - }); + it('should destroy client after reaching maxUses limit', async () => { + const pool = new Pool({ + project: 'p', + instance: 'i', + database: 'd', + maxUses: 2, + }); - it('should execute onConnect initialization hook when connecting new client', async () => { - let onConnectRan = false; - const pool = new Pool({ - project: 'p', - instance: 'i', - database: 'd', - onConnect: async client => { - onConnectRan = true; - assert.strictEqual(client.isConnected, true); - }, - }); - - const c1 = await pool.connect(); - assert.strictEqual(onConnectRan, true); - await c1.release(); - await pool.end(); - }); + const c1 = await pool.connect(); + await c1.release(); + assert.strictEqual(pool.idleCount, 1); - it('should destroy client and propagate error when onConnect throws', async () => { - const pool = new Pool({ - project: 'p', - instance: 'i', - database: 'd', - onConnect: () => { - throw new Error('onConnect initialization failed'); - }, - }); - - try { - await pool.connect(); - assert.fail('Should have thrown onConnect error'); - } catch (err: unknown) { - assert.strictEqual((err as Error).message, 'onConnect initialization failed'); - } - - assert.strictEqual(pool.totalCount, 0); - await pool.end(); - }); + const c1Again = await pool.connect(); + assert.strictEqual(c1, c1Again); - it('should acquire client via connect() callback syntax with done release', done => { - const pool = new Pool({ - project: 'p', - instance: 'i', - database: 'd', - }); - pool.connect((err, client, releaseDone) => { - assert.strictEqual(err, null); - assert.strictEqual(client?.isConnected, true); - if (releaseDone) { - releaseDone(); - } - void pool.end().then(() => done()); - }); - }); + await c1Again.release(); + assert.strictEqual( + pool.idleCount, + 0, + 'Client should be destroyed after 2 uses', + ); + assert.strictEqual(pool.totalCount, 0); + assert.strictEqual(c1.isConnected, false); - it('should destroy client when released with error via connect() done(err) callback', done => { - const pool = new Pool({ - project: 'p', - instance: 'i', - database: 'd', + await pool.end(); }); - pool.connect((err, client, releaseDone) => { - assert.strictEqual(err, null); - assert.strictEqual(pool.totalCount, 1); - if (releaseDone) { - releaseDone(new Error('Fatal connection issue')); - } + + it('should destroy client after maxLifetimeSeconds expires', async () => { + const pool = new Pool({ + project: 'p', + instance: 'i', + database: 'd', + maxLifetimeSeconds: 0.05, // 50ms + }); + + const c1 = await pool.connect(); + await new Promise(r => setTimeout(r, 60)); + await c1.release(); + + assert.strictEqual( + pool.idleCount, + 0, + 'Client should be destroyed due to maxLifetimeSeconds', + ); assert.strictEqual(pool.totalCount, 0); - assert.strictEqual(pool.idleCount, 0); - void pool.end().then(() => done()); + assert.strictEqual(c1.isConnected, false); + + await pool.end(); }); - }); - it('should execute query via pool.query() with async/await', async () => { - const pool = new Pool({ - project: 'p', - instance: 'i', - database: 'd', + it('should evict expired idle client when connect() is called after maxLifetimeSeconds', async () => { + const pool = new Pool({ + project: 'p', + instance: 'i', + database: 'd', + maxLifetimeSeconds: 0.05, // 50ms + idleTimeoutMillis: 0, // do not evict on idle timeout + }); + + const c1 = await pool.connect(); + // Released immediately while still young + await c1.release(); + assert.strictEqual(pool.idleCount, 1); + assert.strictEqual(pool.totalCount, 1); + + // Wait 60ms so client expires while sitting idle in pool + await new Promise(r => setTimeout(r, 60)); + + // Connect again -> should detect expired lifetime on checkout, evict c1, and create fresh c2 + const c2 = await pool.connect(); + assert.notStrictEqual( + c1, + c2, + 'Should create a fresh client rather than reusing expired idle client', + ); + assert.strictEqual( + c1.isConnected, + false, + 'Expired client should have been closed', + ); + assert.strictEqual(c2.isConnected, true); + assert.strictEqual(pool.totalCount, 1); + + await c2.release(); + await pool.end(); }); - const res = await pool.query('SELECT 1'); - assert.strictEqual(res.command, 'SELECT'); - assert.deepStrictEqual(res.rows, []); - await pool.end(); - }); - it('should execute query via pool.query() with callback syntax', done => { - const pool = new Pool({ - project: 'p', - instance: 'i', - database: 'd', + it('should execute onConnect initialization hook when connecting new client', async () => { + let onConnectRan = false; + const pool = new Pool({ + project: 'p', + instance: 'i', + database: 'd', + onConnect: async client => { + onConnectRan = true; + assert.strictEqual(client.isConnected, true); + }, + }); + + const c1 = await pool.connect(); + assert.strictEqual(onConnectRan, true); + await c1.release(); + await pool.end(); }); - void pool.query('SELECT 1', (err, res) => { - assert.strictEqual(err, null); - assert.strictEqual(res?.command, 'SELECT'); - void pool.end().then(() => done()); + + it('should destroy client and propagate error when onConnect throws', async () => { + const pool = new Pool({ + project: 'p', + instance: 'i', + database: 'd', + onConnect: () => { + throw new Error('onConnect initialization failed'); + }, + }); + + try { + await pool.connect(); + assert.fail('Should have thrown onConnect error'); + } catch (err: unknown) { + assert.strictEqual( + (err as Error).message, + 'onConnect initialization failed', + ); + } + + assert.strictEqual(pool.totalCount, 0); + await pool.end(); }); - }); - it('should resolve callback when passing Query instance and 3rd argument callback to pool.query()', done => { - const pool = new Pool({ - project: 'p', - instance: 'i', - database: 'd', + it('should acquire client via connect() callback syntax with done release', done => { + const pool = new Pool({ + project: 'p', + instance: 'i', + database: 'd', + }); + pool.connect((err, client, releaseDone) => { + assert.strictEqual(err, null); + assert.strictEqual(client?.isConnected, true); + if (releaseDone) { + releaseDone(); + } + void pool.end().then(() => done()); + }); }); - const q = new Query('SELECT $1', [42]); - void pool.query(q, [42], (err, res) => { - assert.strictEqual(err, null); - assert.strictEqual(res?.command, 'SELECT'); - void pool.end().then(() => done()); + + it('should destroy client when released with error via connect() done(err) callback', done => { + const pool = new Pool({ + project: 'p', + instance: 'i', + database: 'd', + }); + pool.connect((err, client, releaseDone) => { + assert.strictEqual(err, null); + assert.strictEqual(pool.totalCount, 1); + if (releaseDone) { + releaseDone(new Error('Fatal connection issue')); + } + assert.strictEqual(pool.totalCount, 0); + assert.strictEqual(pool.idleCount, 0); + void pool.end().then(() => done()); + }); }); - }); - it('should invoke callback exactly once when pool.query() fails during connection acquisition', done => { - const originalProject = process.env.GOOGLE_CLOUD_PROJECT; - delete process.env.GOOGLE_CLOUD_PROJECT; - const pool = new Pool({}); - let callCount = 0; - void pool.query('SELECT 1', (err, res) => { - if (originalProject !== undefined) { - process.env.GOOGLE_CLOUD_PROJECT = originalProject; - } else { - delete process.env.GOOGLE_CLOUD_PROJECT; - } - callCount++; - assert.strictEqual(res, undefined); - assert.strictEqual(callCount, 1); - assert.strictEqual(err instanceof Error, true); - assert.match(err!.message, /Invalid Spanner connection configuration/); - done(); + it('should execute query via pool.query() with async/await', async () => { + const pool = new Pool({ + project: 'p', + instance: 'i', + database: 'd', + }); + const res = await pool.query('SELECT 1'); + assert.strictEqual(res.command, 'SELECT'); + assert.strictEqual(res.rowCount, 1); + assert.deepStrictEqual(res.rows, [{'?column?': '1'}]); + assert.strictEqual(res.fields.length, 1); + await pool.end(); }); - }); - it('should invoke callback exactly once and NOT emit error event when pool.query() fails', async () => { - const pool = new Pool({ - project: 'p', - instance: 'i', - database: 'd', + it('should execute query via pool.query() with callback syntax', done => { + const pool = new Pool({ + project: 'p', + instance: 'i', + database: 'd', + }); + void pool.query('SELECT 1', (err, res) => { + assert.strictEqual(err, null); + assert.strictEqual(res?.command, 'SELECT'); + void pool.end().then(() => done()); + }); }); - let callCount = 0; - let errorEventEmitted = false; - const q = new Query(''); - void q.on('error', () => { - errorEventEmitted = true; + it('should resolve callback when passing Query instance and 3rd argument callback to pool.query()', done => { + const pool = new Pool({ + project: 'p', + instance: 'i', + database: 'd', + }); + const q = new Query('SELECT $1', [42]); + void pool.query(q, [42], (err, res) => { + assert.strictEqual(err, null); + assert.strictEqual(res?.command, 'SELECT'); + void pool.end().then(() => done()); + }); }); - await new Promise(resolve => { - void pool.query(q, undefined, (err, res) => { + it('should invoke callback exactly once when pool.query() fails during connection acquisition', done => { + const originalProject = process.env.GOOGLE_CLOUD_PROJECT; + delete process.env.GOOGLE_CLOUD_PROJECT; + const pool = new Pool({}); + let callCount = 0; + void pool.query('SELECT 1', (err, res) => { + if (originalProject !== undefined) { + process.env.GOOGLE_CLOUD_PROJECT = originalProject; + } else { + delete process.env.GOOGLE_CLOUD_PROJECT; + } callCount++; assert.strictEqual(res, undefined); assert.strictEqual(callCount, 1); assert.strictEqual(err instanceof Error, true); - setTimeout(resolve, 20); + assert.match(err!.message, /Invalid Spanner connection configuration/); + done(); }); }); - assert.strictEqual( - errorEventEmitted, - false, - 'error event should not be emitted when callback is provided', - ); - await pool.end(); - }); + it('should invoke callback exactly once and NOT emit error event when pool.query() fails', async () => { + const pool = new Pool({ + project: 'p', + instance: 'i', + database: 'd', + }); + let callCount = 0; + let errorEventEmitted = false; - it('should retain and return client to idle pool when pool.query() encounters a query execution error', async () => { - const pool = new Pool({ - project: 'p', - instance: 'i', - database: 'd', - }); + const q = new Query(''); + void q.on('error', () => { + errorEventEmitted = true; + }); - const origQuery = Client.prototype.query; - (Client.prototype as unknown as {query: Function}).query = async () => { - throw new Error('Table not found: users'); - }; + await new Promise(resolve => { + void pool.query(q, undefined, (err, res) => { + callCount++; + assert.strictEqual(res, undefined); + assert.strictEqual(callCount, 1); + assert.strictEqual(err instanceof Error, true); + setTimeout(resolve, 20); + }); + }); - try { - await pool.query('SELECT * FROM users'); - assert.fail('Should have failed on query execution'); - } catch (err: unknown) { - assert.strictEqual((err as Error).message, 'Table not found: users'); - } finally { - Client.prototype.query = origQuery; - } + assert.strictEqual( + errorEventEmitted, + false, + 'error event should not be emitted when callback is provided', + ); + await pool.end(); + }); - // Client should NOT be destroyed; it should be returned to idle pool - assert.strictEqual(pool.idleCount, 1, 'Client should be returned to idle pool'); - assert.strictEqual(pool.totalCount, 1); + it('should retain and return client to idle pool when pool.query() encounters a query execution error', async () => { + const pool = new Pool({ + project: 'p', + instance: 'i', + database: 'd', + }); - await pool.end(); - }); + const origQuery = Client.prototype.query; + (Client.prototype as unknown as {query: Function}).query = async () => { + throw new Error('Table not found: users'); + }; + + try { + await pool.query('SELECT * FROM users'); + assert.fail('Should have failed on query execution'); + } catch (err: unknown) { + assert.strictEqual((err as Error).message, 'Table not found: users'); + } finally { + Client.prototype.query = origQuery; + } - it('should prevent new client acquisitions after pool.end()', async () => { - const pool = new Pool({ - project: 'p', - instance: 'i', - database: 'd', - }); - await pool.end(); - try { - await pool.connect(); - assert.fail('Should have thrown error on ending pool'); - } catch (err: unknown) { + // Client should NOT be destroyed; it should be returned to idle pool assert.strictEqual( - (err as Error).message, - 'Cannot acquire client from ending pool', + pool.idleCount, + 1, + 'Client should be returned to idle pool', ); - } - }); + assert.strictEqual(pool.totalCount, 1); - it('should reject connect() and destroy client if pool.end() is called during connection handshake', async () => { - const pool = new Pool({ - project: 'p', - instance: 'i', - database: 'd', - onConnect: async () => { - // Wait 40ms during onConnect hook - await new Promise(r => setTimeout(r, 40)); - }, + await pool.end(); }); - const connectPromise = pool.connect(); + it('should prevent new client acquisitions after pool.end()', async () => { + const pool = new Pool({ + project: 'p', + instance: 'i', + database: 'd', + }); + await pool.end(); + try { + await pool.connect(); + assert.fail('Should have thrown error on ending pool'); + } catch (err: unknown) { + assert.strictEqual( + (err as Error).message, + 'Cannot acquire client from ending pool', + ); + } + }); - // Call pool.end() while connect() / onConnect is in progress - await new Promise(r => setTimeout(r, 10)); - const endPromise = pool.end(); + it('should reject connect() and destroy client if pool.end() is called during connection handshake', async () => { + const pool = new Pool({ + project: 'p', + instance: 'i', + database: 'd', + onConnect: async () => { + // Wait 40ms during onConnect hook + await new Promise(r => setTimeout(r, 40)); + }, + }); - try { - await connectPromise; - assert.fail('connect() should have been rejected'); - } catch (err: unknown) { - assert.strictEqual( - (err as Error).message, - 'Cannot acquire client from ending pool', - ); - } + const connectPromise = pool.connect(); - await endPromise; - assert.strictEqual(pool.totalCount, 0); - }); + // Call pool.end() while connect() / onConnect is in progress + await new Promise(r => setTimeout(r, 10)); + const endPromise = pool.end(); - it('should reject pool.query() calls after pool.end() and invoke callback with error', done => { - const pool = new Pool({ - project: 'p', - instance: 'i', - database: 'd', + try { + await connectPromise; + assert.fail('connect() should have been rejected'); + } catch (err: unknown) { + assert.strictEqual( + (err as Error).message, + 'Cannot acquire client from ending pool', + ); + } + + await endPromise; + assert.strictEqual(pool.totalCount, 0); }); - void pool.end().then(() => { - void pool.query('SELECT 1', (err, res) => { - assert.strictEqual(res, undefined); - assert.strictEqual(err instanceof Error, true); - assert.match(err!.message, /Cannot acquire client from ending pool/); - done(); + + it('should reject pool.query() calls after pool.end() and invoke callback with error', done => { + const pool = new Pool({ + project: 'p', + instance: 'i', + database: 'd', + }); + void pool.end().then(() => { + void pool.query('SELECT 1', (err, res) => { + assert.strictEqual(res, undefined); + assert.strictEqual(err instanceof Error, true); + assert.match(err!.message, /Cannot acquire client from ending pool/); + done(); + }); }); }); - }); - it('should ensure client is released before user callback executes in pool.query()', done => { - const pool = new Pool({ - project: 'p', - instance: 'i', - database: 'd', - }); - let clientReleased = false; - - // Override _doConnect to track client.release call sequence - const originalDoConnect = ( - pool as unknown as { - _doConnect: () => Promise<{ - release: () => Promise; - query: ( - q: unknown, - v?: unknown[], - ) => Promise<{command: string; rows: []; fields: []; rowCount: 0}>; - }>; - } - )._doConnect.bind(pool); - (pool as unknown as {_doConnect: () => Promise})._doConnect = - async () => { - const client = await originalDoConnect(); - const originalRelease = client.release.bind(client); - client.release = async () => { - clientReleased = true; - await originalRelease(); + it('should ensure client is released before user callback executes in pool.query()', done => { + const pool = new Pool({ + project: 'p', + instance: 'i', + database: 'd', + }); + let clientReleased = false; + + // Override _doConnect to track client.release call sequence + const originalDoConnect = ( + pool as unknown as { + _doConnect: () => Promise<{ + release: () => Promise; + query: ( + q: unknown, + v?: unknown[], + ) => Promise<{command: string; rows: []; fields: []; rowCount: 0}>; + }>; + } + )._doConnect.bind(pool); + (pool as unknown as {_doConnect: () => Promise})._doConnect = + async () => { + const client = await originalDoConnect(); + const originalRelease = client.release.bind(client); + client.release = async () => { + clientReleased = true; + await originalRelease(); + }; + return client; }; - return client; - }; - void pool.query('SELECT 1', (err, res) => { - assert.strictEqual(err, null); - assert.strictEqual(res?.command, 'SELECT'); - assert.strictEqual( - clientReleased, - true, - 'Client release must complete BEFORE user callback is executed', - ); - void pool.end().then(() => done()); + void pool.query('SELECT 1', (err, res) => { + assert.strictEqual(err, null); + assert.strictEqual(res?.command, 'SELECT'); + assert.strictEqual( + clientReleased, + true, + 'Client release must complete BEFORE user callback is executed', + ); + void pool.end().then(() => done()); + }); }); - }); - it('should end pool using pool.end() callback syntax', done => { - const pool = new Pool({ - project: 'p', - instance: 'i', - database: 'd', - }); - pool.end(() => { - done(); + it('should end pool using pool.end() callback syntax', done => { + const pool = new Pool({ + project: 'p', + instance: 'i', + database: 'd', + }); + pool.end(() => { + done(); + }); }); - }); - it('should emit error event on Query when pool.query() query execution fails without callback', done => { - const pool = new Pool({ - project: 'p', - instance: 'i', - database: 'd', - }); - const q = new Query(''); - void q.on('error', err => { - assert.strictEqual(err instanceof Error, true); - void pool.end().then(() => done()); + it('should emit error event on Query when pool.query() query execution fails without callback', done => { + const pool = new Pool({ + project: 'p', + instance: 'i', + database: 'd', + }); + const q = new Query(''); + void q.on('error', err => { + assert.strictEqual(err instanceof Error, true); + void pool.end().then(() => done()); + }); + void pool.query(q).catch(() => {}); }); - void pool.query(q).catch(() => {}); - }); - it('should emit error event on Query when pool.query() connection acquisition fails without callback', done => { - const pool = new Pool({}); - const q = new Query('SELECT 1'); - void q.on('error', err => { - assert.match(err.message, /Invalid Spanner connection configuration/); - done(); + it('should emit error event on Query when pool.query() connection acquisition fails without callback', done => { + const pool = new Pool({}); + const q = new Query('SELECT 1'); + void q.on('error', err => { + assert.match(err.message, /Invalid Spanner connection configuration/); + done(); + }); + void pool.query(q).catch(() => {}); }); - void pool.query(q).catch(() => {}); - }); - it('should emit end event on Pool.query() only after client.release() completes', async () => { - const pool = new Pool({ - project: 'p', - instance: 'i', - database: 'd', - }); - let releaseCompleted = false; - - const poolAny = pool as unknown as { - _doConnect: () => Promise; - }; - const origConnect = poolAny._doConnect.bind(pool); - poolAny._doConnect = async () => { - const c = await origConnect(); - const origRelease = c.release.bind(c); - c.release = async () => { - await new Promise(r => setTimeout(r, 40)); - releaseCompleted = true; - return origRelease(); + it('should emit end event on Pool.query() only after client.release() completes', async () => { + const pool = new Pool({ + project: 'p', + instance: 'i', + database: 'd', + }); + let releaseCompleted = false; + + const poolAny = pool as unknown as { + _doConnect: () => Promise; + }; + const origConnect = poolAny._doConnect.bind(pool); + poolAny._doConnect = async () => { + const c = await origConnect(); + const origRelease = c.release.bind(c); + c.release = async () => { + await new Promise(r => setTimeout(r, 40)); + releaseCompleted = true; + return origRelease(); + }; + return c; }; - return c; - }; - let releaseStatusWhenEndEmitted = false; - await new Promise((resolve, reject) => { - const q = pool.query('SELECT 1'); - void q.on('end', () => { - releaseStatusWhenEndEmitted = releaseCompleted; + let releaseStatusWhenEndEmitted = false; + await new Promise((resolve, reject) => { + const q = pool.query('SELECT 1'); + void q.on('end', () => { + releaseStatusWhenEndEmitted = releaseCompleted; + }); + void q.then(() => setTimeout(resolve, 50)).catch(reject); }); - void q.then(() => setTimeout(resolve, 50)).catch(reject); - }); - assert.strictEqual( - releaseStatusWhenEndEmitted, - true, - 'client.release() should complete before end event is emitted on pool.query()', - ); - }); - - it('should handle concurrent pool.end() calls gracefully and notify all callers', async () => { - const pool = new Pool({ - project: 'p', - instance: 'i', - database: 'd', + assert.strictEqual( + releaseStatusWhenEndEmitted, + true, + 'client.release() should complete before end event is emitted on pool.query()', + ); }); - const c = await pool.connect(); - setTimeout(() => { - void c.release(); - }, 40); + it('should handle concurrent pool.end() calls gracefully and notify all callers', async () => { + const pool = new Pool({ + project: 'p', + instance: 'i', + database: 'd', + }); - // Call pool.end() concurrently 3 times - await Promise.all([pool.end(), pool.end(), pool.end()]); + const c = await pool.connect(); + setTimeout(() => { + void c.release(); + }, 40); - assert.strictEqual(pool.totalCount, 0); - assert.strictEqual(pool.idleCount, 0); - }); + // Call pool.end() concurrently 3 times + await Promise.all([pool.end(), pool.end(), pool.end()]); - it('should drain active in-flight queries before pool.end() resolves', async () => { - const pool = new Pool({ - project: 'p', - instance: 'i', - database: 'd', + assert.strictEqual(pool.totalCount, 0); + assert.strictEqual(pool.idleCount, 0); }); - const c1 = await pool.connect(); - let queryFinished = false; + it('should drain active in-flight queries before pool.end() resolves', async () => { + const pool = new Pool({ + project: 'p', + instance: 'i', + database: 'd', + }); - // Simulate active query completing after 50ms - setTimeout(() => { - queryFinished = true; - void c1.release(); - }, 50); + const c1 = await pool.connect(); + let queryFinished = false; - assert.strictEqual(pool.totalCount, 1); - await pool.end(); + // Simulate active query completing after 50ms + setTimeout(() => { + queryFinished = true; + void c1.release(); + }, 50); - assert.strictEqual(queryFinished, true, 'pool.end() must wait for active in-flight client to finish'); - assert.strictEqual(pool.totalCount, 0); - }); + assert.strictEqual(pool.totalCount, 1); + await pool.end(); - it('should reject queued waitQueue acquirers when pool.end() is called', async () => { - const pool = new Pool({ - project: 'p', - instance: 'i', - database: 'd', - max: 1, + assert.strictEqual( + queryFinished, + true, + 'pool.end() must wait for active in-flight client to finish', + ); + assert.strictEqual(pool.totalCount, 0); }); - const c1 = await pool.connect(); + it('should reject queued waitQueue acquirers when pool.end() is called', async () => { + const pool = new Pool({ + project: 'p', + instance: 'i', + database: 'd', + max: 1, + }); - let waiterRejected = false; - let waiterErrorMsg = ''; + const c1 = await pool.connect(); - const p2 = pool.connect().catch((err: Error) => { - waiterRejected = true; - waiterErrorMsg = err.message; - }); + let waiterRejected = false; + let waiterErrorMsg = ''; - assert.strictEqual(pool.waitingCount, 1); + const p2 = pool.connect().catch((err: Error) => { + waiterRejected = true; + waiterErrorMsg = err.message; + }); - // Release c1 after a short delay so pool.end() rejects waitQueue before c1 release - setTimeout(() => { - void c1.release(); - }, 20); + assert.strictEqual(pool.waitingCount, 1); - await pool.end(); - await p2; + // Release c1 after a short delay so pool.end() rejects waitQueue before c1 release + setTimeout(() => { + void c1.release(); + }, 20); - assert.strictEqual(waiterRejected, true); - assert.strictEqual(waiterErrorMsg, 'Cannot acquire client from ending pool'); - assert.strictEqual(pool.waitingCount, 0); - }); + await pool.end(); + await p2; - it('should remove idle client from pool when background error event occurs', async () => { - const pool = new Pool({ - project: 'p', - instance: 'i', - database: 'd', + assert.strictEqual(waiterRejected, true); + assert.strictEqual( + waiterErrorMsg, + 'Cannot acquire client from ending pool', + ); + assert.strictEqual(pool.waitingCount, 0); }); - pool.on('error', () => { - // Prevent unhandled error throw in test harness - }); + it('should remove idle client from pool when background error event occurs', async () => { + const pool = new Pool({ + project: 'p', + instance: 'i', + database: 'd', + }); - const idleClient = await pool.connect(); - await idleClient.release(); - assert.strictEqual(pool.idleCount, 1); - assert.strictEqual(pool.totalCount, 1); + pool.on('error', () => { + // Prevent unhandled error throw in test harness + }); - // Emit a background connection error on the idle client handle - idleClient.emit('error', new Error('Connection reset by peer')); + const idleClient = await pool.connect(); + await idleClient.release(); + assert.strictEqual(pool.idleCount, 1); + assert.strictEqual(pool.totalCount, 1); - // Broken client must be removed from the pool - assert.strictEqual(pool.idleCount, 0, 'Broken idle client should be purged'); - assert.strictEqual(pool.totalCount, 0, 'Broken idle client should be removed from totalCount'); - assert.strictEqual(idleClient.isConnected, false, 'Broken client should be closed'); + // Emit a background connection error on the idle client handle + idleClient.emit('error', new Error('Connection reset by peer')); - await pool.end(); - }); + // Broken client must be removed from the pool + assert.strictEqual( + pool.idleCount, + 0, + 'Broken idle client should be purged', + ); + assert.strictEqual( + pool.totalCount, + 0, + 'Broken idle client should be removed from totalCount', + ); + assert.strictEqual( + idleClient.isConnected, + false, + 'Broken client should be closed', + ); - it('should reject in-flight connection attempt when pool.end() is called concurrently', async () => { - const pool = new Pool({ - project: 'p', - instance: 'i', - database: 'd', + await pool.end(); }); - // Delay client connection to simulate slow handshake - const originalConnect = Client.prototype.connect; - Client.prototype.connect = function () { - return new Promise(resolve => setTimeout(resolve, 60)); - }; - - try { - const connectPromise = pool.connect(); - // Call pool.end() while connection handshake is in-flight - const endPromise = pool.end(); + it('should reject in-flight connection attempt when pool.end() is called concurrently', async () => { + const pool = new Pool({ + project: 'p', + instance: 'i', + database: 'd', + }); - await endPromise; + // Delay client connection to simulate slow handshake + const originalConnect = Client.prototype.connect; + Client.prototype.connect = function () { + return new Promise(resolve => setTimeout(() => resolve(this), 60)); + }; try { - await connectPromise; - assert.fail('Should not allow client acquisition from an ending pool'); - } catch (err: unknown) { - assert.strictEqual((err as Error).message, 'Cannot acquire client from ending pool'); + const connectPromise = pool.connect(); + // Call pool.end() while connection handshake is in-flight + const endPromise = pool.end(); + + await endPromise; + + try { + await connectPromise; + assert.fail( + 'Should not allow client acquisition from an ending pool', + ); + } catch (err: unknown) { + assert.strictEqual( + (err as Error).message, + 'Cannot acquire client from ending pool', + ); + } + } finally { + Client.prototype.connect = originalConnect; } - } finally { - Client.prototype.connect = originalConnect; - } - assert.strictEqual(pool.totalCount, 0); - }); + assert.strictEqual(pool.totalCount, 0); + }); - it('should forward streaming row and fields events from pool.query()', async () => { - const pool = new Pool({ - project: 'p', - instance: 'i', - database: 'd', - }); - - const receivedFields: unknown[] = []; - const receivedRows: unknown[] = []; - - // Mock query execution to emit row/fields events - const originalQuery = Client.prototype.query; - (Client.prototype as unknown as {query: Function}).query = function ( - this: Client, - queryText: unknown, - values?: unknown, - callback?: unknown, - ) { - const q = queryText as Query; - q.emit('fields', [{name: 'id', dataTypeID: 23}]); - q.emit('row', {id: 1}); - return (originalQuery as Function).call( - this, - queryText, - values, - callback, - ); - }; + it('should forward streaming row and fields events from pool.query()', async () => { + const pool = new Pool({ + project: 'p', + instance: 'i', + database: 'd', + }); + + const receivedFields: unknown[] = []; + const receivedRows: unknown[] = []; - try { const query = pool.query('SELECT 1'); - query.on('fields', fields => receivedFields.push(fields)); - query.on('row', row => receivedRows.push(row)); + void query.on('fields', fields => receivedFields.push(fields)); + void query.on('row', row => receivedRows.push(row)); await query; assert.strictEqual(receivedFields.length, 1, 'Should emit fields event'); assert.strictEqual(receivedRows.length, 1, 'Should emit row event'); - } finally { - Client.prototype.query = originalQuery; - } - await pool.end(); + await pool.end(); + }); }); }); diff --git a/handwritten/spanner-driver/test/unit/types_test.ts b/handwritten/spanner-driver/test/unit/types_test.ts new file mode 100644 index 000000000000..10052e1dae9c --- /dev/null +++ b/handwritten/spanner-driver/test/unit/types_test.ts @@ -0,0 +1,301 @@ +// 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 { + BuiltinOids, + TypeOverrides, + parseBool, + parseBytea, + parseFloatVal, + parseInteger, + parsePgArray, + parseString, + parseTimestamp, + types, +} from '../../src/lib/pg/types.js'; +import {FieldDef, getDefaultTypeOverrides} from '../../src/lib/types.js'; +import {Codec} from '../../src/lib/codec.js'; + +describe('Type System & Parsers', () => { + describe('Scalar Type Parsers', () => { + it('should parse boolean values and string variants', () => { + assert.strictEqual(parseBool('true'), true); + assert.strictEqual(parseBool('t'), true); + assert.strictEqual(parseBool('1'), true); + assert.strictEqual(parseBool('yes'), true); + assert.strictEqual(parseBool('false'), false); + assert.strictEqual(parseBool('f'), false); + assert.strictEqual(parseBool('0'), false); + assert.strictEqual(parseBool('no'), false); + assert.strictEqual(parseBool(''), false); + + assert.strictEqual(types.getTypeParser(BuiltinOids.BOOL)('t'), true); + assert.strictEqual(types.getTypeParser(BuiltinOids.BOOL)('f'), false); + }); + + it('should parse integers (INT8)', () => { + assert.strictEqual(parseInteger('42'), 42); + assert.strictEqual(parseInteger('123456'), 123456); + // INT8 returns string by default to prevent 64-bit precision loss + const largeInt = '9223372036854775807'; + assert.strictEqual( + types.getTypeParser(BuiltinOids.INT8)(largeInt), + largeInt, + ); + }); + + it('should parse floating point and decimal numbers (FLOAT4, FLOAT8, NUMERIC)', () => { + assert.strictEqual( + types.getTypeParser(BuiltinOids.FLOAT8)('3.14159'), + 3.14159, + ); + assert.strictEqual(types.getTypeParser(BuiltinOids.FLOAT4)('2.5'), 2.5); + // NUMERIC returns exact string by default + const numStr = '12345678901234567890.123456789'; + assert.strictEqual( + types.getTypeParser(BuiltinOids.NUMERIC)(numStr), + numStr, + ); + }); + + it('should parse text and string types (TEXT, VARCHAR, UUID)', () => { + assert.strictEqual( + types.getTypeParser(BuiltinOids.TEXT)('hello world'), + 'hello world', + ); + assert.strictEqual( + types.getTypeParser(BuiltinOids.VARCHAR)('varchar text'), + 'varchar text', + ); + assert.strictEqual( + types.getTypeParser(BuiltinOids.UUID)( + 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', + ), + 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', + ); + }); + + it('should parse date and timestamp types (DATE, TIMESTAMP, TIMESTAMPTZ)', () => { + assert.strictEqual( + types.getTypeParser(BuiltinOids.DATE)('2026-08-07'), + '2026-08-07', + ); + const parsed = types.getTypeParser(BuiltinOids.TIMESTAMPTZ)( + '2026-08-07 14:30:00.000000+00', + ) as Date; + assert.ok(parsed instanceof Date); + assert.strictEqual(parsed.toISOString(), '2026-08-07T14:30:00.000Z'); + }); + + it('should parse JSON and JSONB types into objects', () => { + const parsed = types.getTypeParser(BuiltinOids.JSONB)( + '{"key":"value","count":10}', + ); + assert.deepStrictEqual(parsed, {key: 'value', count: 10}); + + const parsedArr = types.getTypeParser(BuiltinOids.JSON)( + '[1, 2, "three"]', + ); + assert.deepStrictEqual(parsedArr, [1, 2, 'three']); + }); + + it('should parse BYTEA into Node.js Buffer', () => { + const base64Str = Buffer.from('hello spanner').toString('base64'); + const buf1 = types.getTypeParser(BuiltinOids.BYTEA)(base64Str) as Buffer; + assert.ok(Buffer.isBuffer(buf1)); + assert.strictEqual(buf1.toString('utf8'), 'hello spanner'); + + const hexStr = '\\x6465616462656566'; // 'deadbeef' + const buf2 = types.getTypeParser(BuiltinOids.BYTEA)(hexStr) as Buffer; + assert.ok(Buffer.isBuffer(buf2)); + assert.strictEqual(buf2.toString('hex'), '6465616462656566'); + }); + }); + + describe('PostgreSQL Array Parser', () => { + it('should parse 1D and nested pre-parsed array elements', () => { + assert.deepStrictEqual( + types.getTypeParser(1022)('{1.5,2.5,3.5}'), + [1.5, 2.5, 3.5], + ); + assert.deepStrictEqual(types.getTypeParser(1016)('{"10","20"}'), [ + '10', + '20', + ]); + assert.deepStrictEqual(types.getTypeParser(1000)('{t,f,true,false}'), [ + true, + false, + true, + false, + ]); + assert.deepStrictEqual( + types.getTypeParser(1009)([ + ['a', 'b'], + ['c', 'd'], + ]), + [ + ['a', 'b'], + ['c', 'd'], + ], + ); + }); + + it('should parse arrays with NULL elements, whitespace, and quoted commas', () => { + assert.deepStrictEqual( + parsePgArray('{1,NULL,3,null}', val => (val ? Number(val) : null)), + [1, null, 3, null], + ); + assert.deepStrictEqual( + parsePgArray('{"hello, world", "foo, bar"}', val => String(val)), + ['hello, world', 'foo, bar'], + ); + assert.deepStrictEqual(parsePgArray('{}'), []); + assert.deepStrictEqual(parsePgArray(null), []); + }); + }); + + describe('TypeOverrides Scoping & Hierarchy', () => { + it('should allow custom parser registration and support format parameter overload', () => { + const overrides = new TypeOverrides(); + overrides.setTypeParser(BuiltinOids.INT8, 'text', val => BigInt(val)); + assert.strictEqual( + overrides.getTypeParser(BuiltinOids.INT8)('100'), + BigInt(100), + ); + + assert.throws(() => { + overrides.setTypeParser( + BuiltinOids.INT8, + 'not-a-function' as unknown as (val: unknown) => unknown, + ); + }, /Type parser must be a function/); + }); + + it('should allow registering custom array parsers on array OIDs', () => { + const overrides = new TypeOverrides(); + overrides.setTypeParser(1016, val => + overrides.arrayParser(val, x => Number(x) * 10), + ); + assert.deepStrictEqual(overrides.getTypeParser(1016)('{1,2}'), [10, 20]); + }); + + it('should support hierarchical parent fallback in TypeOverrides', () => { + const parent = new TypeOverrides(); + parent.setTypeParser(BuiltinOids.INT8, val => BigInt(val)); + + const child = new TypeOverrides(parent); + // Child inherits parent's INT8 parser + assert.strictEqual( + child.getTypeParser(BuiltinOids.INT8)('42'), + BigInt(42), + ); + + // Child override takes precedence + child.setTypeParser(BuiltinOids.INT8, val => Number(val)); + assert.strictEqual(child.getTypeParser(BuiltinOids.INT8)('42'), 42); + // Parent remains unchanged + assert.strictEqual( + parent.getTypeParser(BuiltinOids.INT8)('42'), + BigInt(42), + ); + }); + + it('should provide arrayParser helper and throw on non-numeric OID', () => { + const overrides = new TypeOverrides(); + assert.deepStrictEqual( + overrides.arrayParser('{10,20}', val => Number(val) + 1), + [11, 21], + ); + + assert.throws(() => { + overrides.getTypeParser('INVALID_OID'); + }, /Invalid PostgreSQL OID/); + }); + }); + + describe('Row Decoding (Codec.decodeRow)', () => { + const fields: FieldDef[] = [ + {name: 'id', dataTypeID: BuiltinOids.FLOAT8}, + {name: 'name', dataTypeID: BuiltinOids.TEXT}, + {name: 'active', dataTypeID: BuiltinOids.BOOL}, + {name: 'tags', dataTypeID: 1009}, + ]; + const rawRow = ['101', 'Spanner', 't', '{"cloud","db"}']; + + it('should decode row in object mode', () => { + const parsers = fields.map(f => types.getTypeParser(f.dataTypeID)); + const decoded = Codec.decodeRow>( + rawRow, + fields, + parsers, + 'object', + ); + assert.deepStrictEqual(decoded, { + id: 101, + name: 'Spanner', + active: true, + tags: ['cloud', 'db'], + }); + }); + + it('should decode row in array mode', () => { + const parsers = fields.map(f => types.getTypeParser(f.dataTypeID)); + const decoded = Codec.decodeRow( + rawRow, + fields, + parsers, + 'array', + ); + assert.deepStrictEqual(decoded, [101, 'Spanner', true, ['cloud', 'db']]); + }); + + it('should decode rows with custom TypeOverrides and fallback', () => { + const customOverrides = new TypeOverrides(); + customOverrides.setTypeParser(BuiltinOids.FLOAT8, val => `id_${val}`); + + const parsers = Codec.getTypeParsers(fields, customOverrides); + const decoded = Codec.decodeRow>( + rawRow, + fields, + parsers, + ); + assert.strictEqual(decoded.id, 'id_101'); + assert.strictEqual(decoded.name, 'Spanner'); + }); + + it('should return default PG type overrides via getDefaultTypeOverrides', () => { + const defaultTypes = getDefaultTypeOverrides('pg'); + assert.strictEqual( + defaultTypes.getTypeParser(BuiltinOids.BOOL)('t'), + true, + ); + assert.strictEqual( + defaultTypes.getTypeParser(BuiltinOids.FLOAT8)('42'), + 42, + ); + }); + + it('should handle pre-parsed values gracefully in parsers', () => { + const date = new Date('2026-08-11T10:00:00.000Z'); + assert.strictEqual(parseTimestamp(date), date); + assert.strictEqual(parseBool(true), true); + assert.strictEqual(parseInteger(42), 42); + assert.strictEqual(parseFloatVal(3.14), 3.14); + assert.strictEqual(parseString(100), '100'); + assert.strictEqual(parseBytea(Buffer.from('hi')).toString(), 'hi'); + }); + }); +}); diff --git a/handwritten/spanner-driver/tsconfig.cjs.json b/handwritten/spanner-driver/tsconfig.cjs.json index 0b474f9d2a10..8dbb55a2d4bf 100644 --- a/handwritten/spanner-driver/tsconfig.cjs.json +++ b/handwritten/spanner-driver/tsconfig.cjs.json @@ -10,5 +10,12 @@ "esModuleInterop": true, "types": ["node", "mocha"] }, - "include": ["src/*.ts", "src/**/*.ts", "test/*.ts", "test/**/*.ts"] + "include": [ + "src/*.ts", + "src/**/*.ts", + "test/*.ts", + "test/**/*.ts", + "system-test/*.ts", + "system-test/**/*.ts" + ] } diff --git a/handwritten/spanner-driver/tsconfig.json b/handwritten/spanner-driver/tsconfig.json index 709f57273b7f..5c81af13ac5b 100644 --- a/handwritten/spanner-driver/tsconfig.json +++ b/handwritten/spanner-driver/tsconfig.json @@ -9,5 +9,12 @@ "skipLibCheck": true, "types": ["node", "mocha"] }, - "include": ["src/*.ts", "src/**/*.ts", "test/*.ts", "test/**/*.ts"] + "include": [ + "src/*.ts", + "src/**/*.ts", + "test/*.ts", + "test/**/*.ts", + "system-test/*.ts", + "system-test/**/*.ts" + ] }