diff --git a/handwritten/spanner-driver/.repo-metadata.json b/handwritten/spanner-driver/.repo-metadata.json index cdb3c8f269c..cbdc9c5216b 100644 --- a/handwritten/spanner-driver/.repo-metadata.json +++ b/handwritten/spanner-driver/.repo-metadata.json @@ -1,6 +1,6 @@ { "name": "spanner-driver", - "name_pretty": "Cloud Spanner Driver", + "name_pretty": "Spanner Driver", "product_documentation": "https://cloud.google.com/spanner/docs/", "client_documentation": "https://cloud.google.com/nodejs/docs/reference/spanner/latest", "issue_tracker": "https://issuetracker.google.com/issues?q=componentid:190851%2B%20status:open", diff --git a/handwritten/spanner-driver/README.md b/handwritten/spanner-driver/README.md index c1b51e32e99..7e6b8c5dc0a 100644 --- a/handwritten/spanner-driver/README.md +++ b/handwritten/spanner-driver/README.md @@ -1,5 +1,133 @@ -# Spanner Node.js Driver +# Google Spanner Node.js Driver (`@google-cloud/spanner-driver`) -Node.js driver for Google Spanner. +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. -**Note**: This package is currently under development and should not be used. +[![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) + +--- + +## Key Features + +- **`node-postgres` Compatibility**: Drop-in compatible `Client` and `Pool` interfaces matching standard PostgreSQL drivers. +- **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. + +--- + +## Installation + +```bash +npm install @google-cloud/spanner-driver +``` + +--- + +## Usage Examples + +### 1. Connecting via `Client` + +```typescript +import { Client } from '@google-cloud/spanner-driver'; + +// Option A: Configuration Object +const client = new Client({ + project: 'my-gcp-project', + instance: 'my-spanner-instance', + database: 'my-spanner-database', +}); + +// Option B: Connection DSN String or postgresql:// URL +// const client = new Client('projects/my-gcp-project/instances/my-spanner-instance/databases/my-spanner-database'); + +async function main() { + 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', + ['ACTIVE'] + ); + + console.log(`Returned ${result.rowCount} rows:`); + console.log(result.rows); + + // Close connection (or call client.release()) + await client.end(); +} + +main().catch(console.error); +``` + +### 2. Connection Pooling via `Pool` + +```typescript +import { Pool } from '@google-cloud/spanner-driver'; + +const pool = new Pool({ + project: 'my-gcp-project', + instance: 'my-spanner-instance', + database: 'my-spanner-database', +}); + +async function runPoolQueries() { + // Query executed using auto-acquired and auto-released pool connection + const res = await pool.query('SELECT current_timestamp()'); + console.log('Result:', res.rows); + + // Manually checkout client from pool + const client = await pool.connect(); + try { + const userRes = await client.query('SELECT * FROM users WHERE user_id = $1', [101]); + console.log('User:', userRes.rows); + } finally { + // Release client back to pool + client.release(); + } +} + +// Drain pool on application shutdown +async function shutdown() { + await pool.end(); +} +``` + +### 3. Streaming Rows & Callbacks + +```typescript +import { Client } from '@google-cloud/spanner-driver'; + +const client = new Client({ + project: 'my-gcp-project', + instance: 'my-spanner-instance', + database: 'my-spanner-database', +}); + +// Streaming row events +client.query('SELECT * FROM large_table') + .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)); +``` + +--- + +## Public API Reference + +| Export | Type | Description | +| :--- | :--- | :--- | +| `Client` | Class | Database connection client (`connect()`, `query()`, `release()`, `end()`). | +| `Pool` | Class | Connection pool (`connect()`, `query()`, `end()`). | +| `DatabaseError` | Class | Enriched database error containing PostgreSQL SQLSTATE `.code` and `.severity`. | +| `ClientConfig` | Interface | Client configuration options (`project`, `instance`, `database`, `host`, `port`, `connectionString`). | +| `QueryResult` | Interface | Result set container (`rows`, `fields`, `rowCount`, `command`). | +| `QueryConfig` | Interface | Query options object (`text`, `values`, `rowMode`). | +| `escapeIdentifier` | Function | Escapes PostgreSQL identifiers with double quotes (`"my_table"`). | +| `escapeLiteral` | Function | Escapes PostgreSQL string literals with single quotes (`'val'`). | + +--- + +## License + +[Apache 2.0](LICENSE) - Google LLC diff --git a/handwritten/spanner-driver/package.json b/handwritten/spanner-driver/package.json index 5413cb7b74d..00539eaa4c1 100644 --- a/handwritten/spanner-driver/package.json +++ b/handwritten/spanner-driver/package.json @@ -1,7 +1,7 @@ { "name": "@google-cloud/spanner-driver", "version": "0.1.0", - "description": "Node.js driver for Google Cloud Spanner", + "description": "Node.js driver for Google Spanner", "repository": { "type": "git", "url": "https://github.com/googleapis/google-cloud-node.git", diff --git a/handwritten/spanner-driver/src/index.ts b/handwritten/spanner-driver/src/index.ts index ace3ec43078..c595c9a184a 100644 --- a/handwritten/spanner-driver/src/index.ts +++ b/handwritten/spanner-driver/src/index.ts @@ -12,8 +12,23 @@ // See the License for the specific language governing permissions and // limitations under the License. -import {DatabaseError} from './lib/errors.js'; +import {Client} from './lib/client.js'; import {ClientConfig} from './lib/config.js'; +import {DatabaseError} from './lib/errors.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'; -export {DatabaseError, ClientConfig, escapeIdentifier, escapeLiteral}; +export { + Client, + ClientConfig, + DatabaseError, + FieldDef, + Pool, + Query, + QueryConfig, + QueryResult, + escapeIdentifier, + escapeLiteral, +}; diff --git a/handwritten/spanner-driver/src/lib/client.ts b/handwritten/spanner-driver/src/lib/client.ts new file mode 100644 index 00000000000..cbfc8ea5caa --- /dev/null +++ b/handwritten/spanner-driver/src/lib/client.ts @@ -0,0 +1,317 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import {EventEmitter} from 'events'; +import {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 {dispatchQueryError, normalizeQueryArgs} from './utilities.js'; + +/** + * Task entry stored in single-connection query execution queue. + * Ensures queries execute sequentially per client connection. + */ +interface QueryTask { + run: () => Promise; + cancel?: (err: Error) => void; +} + +/** + * Client class representing a single database connection to Google Spanner. + * + * Handles DSN resolution, connection lifecycle (`connect`/`end`/`release`), sequential query + * execution, transaction state tracking (`txStatus`), and dialect-aware error enrichment. + */ +export class Client extends EventEmitter { + /** Resolved ClientConfig object passed on instantiation. */ + readonly config: ClientConfig; + + /** Fully formatted Spanner DSN resource string (e.g. `projects/p/instances/i/databases/d`). */ + readonly dsn: string; + + /** Active SQL dialect (defaults to `'pg'`). */ + readonly dialect: Dialect = DEFAULT_DIALECT; + + /** Boolean indicating whether connection has been established. */ + public isConnected = false; + + /** + * Active transaction status code: + * - `'I'` (Idle): Outside transaction block. + * - `'T'` (Transaction): Active transaction block. + * - `'E'` (Error): Transaction failed due to query error inside transaction block. + * + * Updated dynamically from the native backend driver (`spannerlib-node`) upon query execution. + */ + public txStatus: 'I' | 'T' | 'E' = 'I'; + + /** Internal task queue managing sequential query execution. */ + private queryQueue: QueryTask[] = []; + + /** Boolean flag tracking active query execution state. */ + private isExecuting = false; + + /** Boolean flag tracking whether client has been explicitly closed via end(). */ + private isEnded = false; + + /** Cached Promise for in-flight connect() calls. */ + private connectPromise?: Promise; + + /** + * Instantiates a new Spanner Client connection handle. + * + * @param config - Connection string (e.g. `projects/p/instances/i/databases/d` or `postgresql://...`) or `ClientConfig` object. + */ + constructor(config?: string | ClientConfig) { + super(); + this.config = + typeof config === 'string' ? {connectionString: config} : config || {}; + this.dsn = resolveDsn(config); + } + + /** + * Establishes a connection to Google Spanner using the resolved DSN. + * Supports both Promise (`await client.connect()`) and Node callback (`client.connect(cb)`) forms. + * + * @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 { + if (this.isConnected) { + if (callback) { + process.nextTick(() => callback(null)); + return; + } + return Promise.resolve(); + } + + if (!this.connectPromise) { + this.connectPromise = (async () => { + try { + await this._doConnect(); + } finally { + this.connectPromise = undefined; + } + })(); + } + + if (callback) { + this.connectPromise + .then(() => callback(null)) + .catch(err => callback(err)); + return; + } + return this.connectPromise; + } + + private async _doConnect(): Promise { + 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) { + this.isConnected = true; + } + } catch (err) { + throw enrichError(err, this.dialect); + } + } + + /** + * Executes a SQL query against Google Spanner. + * Supports Promises (`await client.query(sql)`), callbacks (`client.query(sql, cb)`), + * and streaming row events (`client.query(sql).on('row', cb)`). + * + * @template R - Row result shape type (defaults to `Record`). + * @param queryText - SQL statement text string, `QueryConfig` configuration object, or existing `Query` instance. + * @param values - Optional positional query parameters ($1, $2, etc.) or callback function. + * @param callback - Optional Node callback function receiving `(err, result)`. + * @returns Executable `Query` instance implementing Thenable interface and EventEmitter. + */ + public query>( + queryText: string | QueryConfig | Query>, + values?: unknown[] | QueryCallback>, + callback?: QueryCallback>, + ): Query> { + const {query, actualCallback} = normalizeQueryArgs( + queryText, + values, + callback, + ); + + const sqlText = query.text; + const sqlValues = query.values; + + if (typeof sqlText !== 'string' || !sqlText.trim()) { + const err = enrichError( + new Error('Query text must be a non-empty string'), + this.dialect, + ); + dispatchQueryError(err, query, actualCallback); + const executionPromise = Promise.reject>(err); + executionPromise.catch(() => {}); + query.setPromise(executionPromise); + return query; + } + + if ( + sqlValues !== undefined && + sqlValues !== null && + !Array.isArray(sqlValues) + ) { + const err = enrichError( + new Error('Query values must be an Array'), + 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) => { + resolveTask = res; + rejectTask = rej; + }); + + query.setPromise(executionPromise); + executionPromise.catch(() => {}); + + const task: QueryTask> = { + run: async () => { + try { + if (this.isEnded) { + throw new Error('Client was closed'); + } + 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', + }; + + query.emit('end', result); + if (actualCallback) { + process.nextTick(() => actualCallback!(null, result)); + } + resolveTask(result); + return result; + } catch (err: unknown) { + const enriched = enrichError(err, this.dialect); + dispatchQueryError(enriched, query, actualCallback); + rejectTask(enriched); + throw enriched; + } + }, + cancel: (err: Error) => { + dispatchQueryError(err, query, actualCallback); + rejectTask(err); + }, + }; + + this.queryQueue.push(task as QueryTask); + void this.processQueue(); + return query; + } + + /** + * Processes query tasks sequentially from the internal queue. + */ + private async processQueue(): Promise { + if (this.queryQueue.length === 0) { + this.emit('drain'); + return; + } + if (this.isExecuting) return; + this.isExecuting = true; + const task = this.queryQueue.shift()!; + try { + await task.run(); + } catch { + // Handled in executionPromise reject + } finally { + this.isExecuting = false; + void this.processQueue(); + } + } + + /** + * Releases the client connection. + * + * TODO(PR 4 - Connection Pooling): Full connection pool recycling (returning active + * clients back to an idle connection queue instead of closing them) will be implemented in PR 4. + * Currently in PR 3 basic pool scaffolding, release() delegates to end() to close the connection. + * + * @param callback - Optional Node callback function. + * @returns Promise resolving when connection is released, or void if callback is passed. + */ + public release(): Promise; + public release(callback: (err: Error | null) => void): void; + public release(callback?: (err: Error | null) => void): Promise | void { + return this.end(callback as (err: Error | null) => void); + } + + /** + * Closes the client connection and resets transaction status. + * Supports both Promise (`await client.end()`) and Node callback (`client.end(cb)`) forms. + * + * @returns Promise resolving when connection is closed, or void if callback is passed. + */ + async end(): Promise; + end(callback: (err: Error | null) => void): void; + end(callback?: (err: Error | null) => void): Promise | void { + if (callback) { + this._doEnd() + .then(() => callback(null)) + .catch(err => callback(err)); + return; + } + return this._doEnd(); + } + + private async _doEnd(): Promise { + this.isEnded = true; + this.isConnected = false; + // Cancel pending queries in queue to prevent execution after client close + const pendingTasks = this.queryQueue; + this.queryQueue = []; + const closeError = new Error('Client was closed'); + for (const task of pendingTasks) { + if (task.cancel) { + task.cancel(closeError); + } + } + } +} diff --git a/handwritten/spanner-driver/src/lib/config.ts b/handwritten/spanner-driver/src/lib/config.ts index 4170a1bd60c..bc256cc308c 100644 --- a/handwritten/spanner-driver/src/lib/config.ts +++ b/handwritten/spanner-driver/src/lib/config.ts @@ -13,7 +13,7 @@ // limitations under the License. /** - * Configuration options for establishing a connection to Google Cloud Spanner. + * Configuration options for establishing a connection to Google Spanner. */ export interface ClientConfig { /** Connection DSN string or postgresql:// URL. */ diff --git a/handwritten/spanner-driver/src/lib/constants.ts b/handwritten/spanner-driver/src/lib/constants.ts index 8a6528c45c9..735d928b774 100644 --- a/handwritten/spanner-driver/src/lib/constants.ts +++ b/handwritten/spanner-driver/src/lib/constants.ts @@ -13,7 +13,7 @@ // limitations under the License. /** - * Supported SQL dialects for Google Cloud Spanner. + * Supported SQL dialects for Google Spanner. * Note: Currently 'pg' (PostgreSQL) dialect is supported. */ export type Dialect = 'pg' | 'googlesql'; diff --git a/handwritten/spanner-driver/src/lib/pool.ts b/handwritten/spanner-driver/src/lib/pool.ts new file mode 100644 index 00000000000..712ccc60b98 --- /dev/null +++ b/handwritten/spanner-driver/src/lib/pool.ts @@ -0,0 +1,178 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import {EventEmitter} from 'events'; +import {Client} from './client.js'; +import {ClientConfig} from './config.js'; +import {Query, QueryCallback} from './query.js'; +import {QueryConfig, QueryResult} from './types.js'; +import {dispatchQueryError, normalizeQueryArgs} from './utilities.js'; + +/** + * Basic Pool class managing database connection instances. + * + * Facilitates client acquisition (`connect`), automatic query execution with connection + * auto-release (`query`), and graceful pool shutdown (`end`). + * + * TODO(PR 4 - Connection Pooling): Full connection pooling features (max connections, + * min idle connections, idle timeouts, connection queueing, and pool event emitters) + * will be expanded in PR 4. + */ +export class Pool extends EventEmitter { + /** Resolved ClientConfig object used when instantiating connections. */ + readonly config: ClientConfig; + + /** Boolean flag tracking whether pool shutdown has been initiated. */ + private isEnding = false; + + /** + * Instantiates a new Spanner Pool. + * + * @param config - Connection string (e.g. `projects/p/instances/i/databases/d`) or `ClientConfig` object. + */ + constructor(config?: string | ClientConfig) { + super(); + this.config = + typeof config === 'string' ? {connectionString: config} : config || {}; + } + + /** + * Acquires a connected `Client` instance from the pool. + * Supports both Promise (`const client = await pool.connect()`) and Node callback (`pool.connect(cb)`). + * + * @returns Promise resolving to connected `Client` instance, or void if callback is provided. + */ + async connect(): Promise; + connect( + callback: (err: Error | null, client?: Client, done?: () => void) => void, + ): void; + connect( + callback?: (err: Error | null, client?: Client, done?: () => void) => void, + ): Promise | void { + if (callback) { + this._doConnect() + .then(client => callback(null, client, () => void client.release())) + .catch(err => callback(err)); + return; + } + return this._doConnect(); + } + + private async _doConnect(): Promise { + if (this.isEnding) { + throw new Error('Cannot acquire client from ending pool'); + } + const client = new Client(this.config); + await client.connect(); + // TODO(PR 4 - Connection Pooling): Override client.release to return client to idle connection pool + client.release = client.end.bind(client); + return client; + } + + /** + * Executes a query by acquiring a client, executing the statement, and automatically releasing the client. + * Supports Promises, callbacks, and streaming row events. + * + * @template R - Row result shape type (defaults to `Record`). + * @param queryText - SQL query string, `QueryConfig` object, or `Query` instance. + * @param values - Positional query parameter array or callback function. + * @param callback - Optional Node callback function. + * @returns Executable `Query` instance implementing Thenable interface. + */ + public query>( + queryText: string | QueryConfig | Query>, + values?: unknown[] | QueryCallback>, + callback?: QueryCallback>, + ): Query> { + const {query, actualCallback} = normalizeQueryArgs( + queryText, + values, + callback, + ); + + const executionPromise = (async () => { + let client: Client; + + // 1. Connection acquisition stage: + // Catches connection failures from _doConnect() before client.query is invoked. + // Notifies actualCallback or error listeners so callback-based queries do not hang. + try { + client = await this._doConnect(); + } catch (err: unknown) { + const errorObj = err instanceof Error ? err : new Error(String(err)); + dispatchQueryError(errorObj, query, actualCallback); + throw errorObj; + } + + // 2. Query execution stage: + // Executed on the acquired client using an internal query instance without callback. + // This ensures client.query does not fire actualCallback prematurely before client.release() completes. + const queryForClient = new Query>( + query.text ?? '', + query.values, + ); + queryForClient.rowMode = query.rowMode; + queryForClient.types = query.types; + void queryForClient.on('row', row => { + void query.emit('row', row); + }); + void queryForClient.on('fields', fields => { + void query.emit('fields', fields); + }); + + let result: QueryResult; + let queryErr: Error | undefined; + + try { + result = await client.query(queryForClient); + query.emit('end', result); + } catch (err: unknown) { + queryErr = err instanceof Error ? err : new Error(String(err)); + } finally { + await client.release(); + } + + if (queryErr) { + dispatchQueryError(queryErr, query, actualCallback); + throw queryErr; + } + + if (actualCallback) { + process.nextTick(() => actualCallback!(null, result!)); + } + return result!; + })(); + + query.setPromise(executionPromise); + executionPromise.catch(() => {}); + return query; + } + + /** + * Drains and shuts down the connection pool. + * Supports both Promise (`await pool.end()`) and Node callback (`pool.end(cb)`) forms. + * + * @returns Promise resolving when pool is closed, or void if callback is passed. + */ + async end(): Promise; + end(callback: () => void): void; + end(callback?: () => void): Promise | void { + this.isEnding = true; + if (callback) { + process.nextTick(callback); + return; + } + return Promise.resolve(); + } +} diff --git a/handwritten/spanner-driver/src/lib/query.ts b/handwritten/spanner-driver/src/lib/query.ts new file mode 100644 index 00000000000..9ffc6c520f3 --- /dev/null +++ b/handwritten/spanner-driver/src/lib/query.ts @@ -0,0 +1,164 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import {EventEmitter} from 'events'; +import {QueryConfig} from './types.js'; + +/** + * Node callback function signature receiving `(err, result)`. + * + * @template T - Result object type. + */ +export type QueryCallback = (err: Error | null, result?: T) => void; + +/** + * Query class representing a SQL statement execution. + * Compatible with node-postgres (`pg.Query`) interface. + * + * Extends `EventEmitter` to support streaming row events (`.on('row', cb)`, `.on('end', cb)`), + * while implementing the Thenable interface (`then`, `catch`, `finally`) to support `async`/`await`. + * + * @template T - Query result return type (defaults to `QueryResult`). + */ +export class Query extends EventEmitter { + /** SQL statement string text. */ + public text?: string; + + /** Positional parameter value array. */ + public values?: unknown[]; + + /** Optional Node callback function attached to this query execution. */ + public callback?: QueryCallback; + + /** Result row formatting mode (`'object'` or `'array'`). */ + public rowMode?: 'array' | 'object'; + + /** Optional custom type parser override registry. */ + public types?: unknown; + + /** Internal promise backing Thenable async/await integration. */ + private promise!: Promise; + private promiseResolver?: { + resolve: (value: T | PromiseLike) => void; + reject: (reason?: unknown) => void; + }; + + /** + * Instantiates a new Query instance. + * + * @param text - SQL query string, `QueryConfig` object, or existing `Query` instance. + * @param values - Positional parameter array or callback function. + * @param callback - Callback function receiving `(err, result)`. + */ + constructor( + text: string | QueryConfig | Query, + values?: unknown[] | QueryCallback, + callback?: QueryCallback, + ) { + super(); + + this.promise = new Promise((resolve, reject) => { + this.promiseResolver = {resolve, reject}; + }); + // Suppress unhandled rejection warnings on default un-awaited promise + this.promise.catch(() => {}); + + if (text instanceof Query) { + this.text = text.text; + this.values = Array.isArray(values) ? values : text.values; + this.rowMode = text.rowMode; + this.types = text.types; + this.callback = + typeof values === 'function' ? values : callback || text.callback; + return; + } + + if (typeof text === 'object' && text !== null) { + this.text = text.text; + this.values = Array.isArray(values) ? values : text.values; + this.rowMode = text.rowMode; + this.types = text.types; + this.callback = typeof values === 'function' ? values : callback; + } else { + this.text = text; + if (typeof values === 'function') { + this.callback = values; + this.values = undefined; + } else { + this.values = values; + this.callback = callback; + } + } + } + + /** + * Implements the thenable `.then()` method enabling `await` integration. + * + * @param onFulfilled - Handler called when query promise resolves successfully. + * @param onRejected - Handler called when query promise is rejected. + * @returns Promise resolving to handler return value. + */ + public then( + onFulfilled?: + | ((value: T) => TResult1 | PromiseLike) + | undefined + | null, + onRejected?: + | ((reason: unknown) => TResult2 | PromiseLike) + | undefined + | null, + ): Promise { + return this.promise.then(onFulfilled, onRejected); + } + + /** + * Implements the `.catch()` method enabling error handling on awaited queries. + * + * @param onRejected - Handler called when query promise is rejected. + * @returns Promise resolving to handler return value. + */ + public catch( + onRejected?: + | ((reason: unknown) => TResult | PromiseLike) + | undefined + | null, + ): Promise { + return this.promise.catch(onRejected); + } + + /** + * Implements the `.finally()` method called when query execution finishes. + * + * @param onFinally - Cleanup callback invoked on completion. + * @returns Promise resolving when query finishes. + */ + public finally(onFinally?: (() => void) | undefined | null): Promise { + return this.promise.finally(onFinally); + } + + /** + * Binds internal Promise backing Thenable operations. + * + * @param promise - Internal execution Promise. + */ + public setPromise(promise: Promise): void { + if (this.promiseResolver) { + const resolver = this.promiseResolver; + this.promiseResolver = undefined; + promise.then(resolver.resolve, resolver.reject); + } else { + this.promise = promise; + } + } +} diff --git a/handwritten/spanner-driver/src/lib/types.ts b/handwritten/spanner-driver/src/lib/types.ts new file mode 100644 index 00000000000..46f41f49241 --- /dev/null +++ b/handwritten/spanner-driver/src/lib/types.ts @@ -0,0 +1,57 @@ +// 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. + +/** + * 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; +} + +/** + * Standard query result. + * + * @template R - Shape of returned result set row objects or tuples. + */ +export interface QueryResult> { + /** Array of result set rows formatted as objects or arrays based on `rowMode`. */ + rows: R[]; + /** Column metadata descriptors matching fields returned by Spanner query. */ + fields: FieldDef[]; + /** Total number of rows affected by SQL statement or returned in result set. */ + rowCount: number; + /** SQL statement command verb inferred from execution (e.g. 'SELECT', 'INSERT', 'UPDATE', 'DELETE'). */ + command: string; +} + +/** + * Configuration object passed when executing parameterized SQL queries. + */ +export interface QueryConfig { + /** SQL statement query text. */ + text: string; + /** Optional positional query parameters ($1, $2, etc.). */ + values?: unknown[]; + /** + * Result row formatting mode: + * - `'object'` (default): Returns rows as key-value objects (`{ id: 1 }`). + * - `'array'`: Returns rows as positionally ordered arrays (`[1]`). + */ + rowMode?: 'array' | 'object'; + /** Custom type parser registry hook for overriding OID data type decoding. */ + types?: unknown; +} diff --git a/handwritten/spanner-driver/src/lib/utilities.ts b/handwritten/spanner-driver/src/lib/utilities.ts new file mode 100644 index 00000000000..9acc11ee8b5 --- /dev/null +++ b/handwritten/spanner-driver/src/lib/utilities.ts @@ -0,0 +1,73 @@ +// 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 {Query, QueryCallback} from './query.js'; +import {QueryConfig, QueryResult} from './types.js'; + +/** + * Normalizes query arguments for `Client.query` and `Pool.query`, resolving + * `Query` instance vs `QueryConfig` and extracting the actual callback function. + * + * @template R - Row result shape type (defaults to `Record`). + * @param queryText - SQL query string, `QueryConfig` object, or `Query` instance. + * @param values - Positional query parameter array or callback function. + * @param callback - Optional Node callback function. + * @returns Object containing normalized `query` instance and `actualCallback`. + */ +export function normalizeQueryArgs>( + queryText: string | QueryConfig | Query>, + values?: unknown[] | QueryCallback>, + callback?: QueryCallback>, +): { + query: Query>; + actualCallback: QueryCallback> | undefined; +} { + let query: Query>; + if (queryText instanceof Query) { + query = queryText; + if (Array.isArray(values)) { + query.values = values; + } + if (typeof values === 'function') { + query.callback = values; + } else if (typeof callback === 'function') { + query.callback = callback; + } + } else { + query = new Query>(queryText, values as unknown[], callback); + } + + return {query, actualCallback: query.callback}; +} + +/** + * Dispatches query errors to a callback function if provided, or emits an `'error'` event + * on the `Query` instance if an error listener is attached (mutually exclusive). + * + * @template T - Query result return shape type. + * @param err - Error instance to dispatch. + * @param query - `Query` instance. + * @param callback - Optional Node callback function. + */ +export function dispatchQueryError( + err: Error, + query: Query, + callback?: QueryCallback, +): void { + if (callback) { + process.nextTick(() => callback(err)); + } else if (query.listenerCount('error') > 0) { + query.emit('error', err); + } +} diff --git a/handwritten/spanner-driver/test/unit/client_test.ts b/handwritten/spanner-driver/test/unit/client_test.ts new file mode 100644 index 00000000000..970ae93d8e4 --- /dev/null +++ b/handwritten/spanner-driver/test/unit/client_test.ts @@ -0,0 +1,380 @@ +// 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 {Client, DatabaseError, Query, QueryResult} from '../../src/index.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'); + }); + + 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); + 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); + 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 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.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 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()); + }); + }); + + 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 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(''); + 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); + }); + + 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/, + ); + } + }); + + 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()); + }); + + it('should clear pending query queue when client.end() is called', async () => { + const client = new Client({ + project: 'p', + instance: 'i', + database: 'd', + }); + 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', + }); + 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', + }); + 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', + }); + 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 delegate release() to end()', async () => { + const client = new Client({ + project: 'p', + instance: 'i', + database: 'd', + }); + 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', + }); + const q = new Query(''); + void q.on('error', err => { + assert.strictEqual(err instanceof DatabaseError, true); + 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', + }); + client.release(err => { + assert.strictEqual(err, null); + assert.strictEqual(client.isConnected, false); + 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(); + }); + void q.catch(() => {}); + }); +}); diff --git a/handwritten/spanner-driver/test/unit/pool_test.ts b/handwritten/spanner-driver/test/unit/pool_test.ts new file mode 100644 index 00000000000..b6fd2986ff4 --- /dev/null +++ b/handwritten/spanner-driver/test/unit/pool_test.ts @@ -0,0 +1,267 @@ +// 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 {Pool, Query, QueryResult} from '../../src/index.js'; + +describe('Pool Class', () => { + it('should instantiate Pool with config object or connection string', () => { + const pool1 = new Pool({ + project: 'p', + instance: 'i', + database: 'd', + }); + assert.strictEqual(pool1.config.project, 'p'); + + const pool2 = new Pool('projects/p/instances/i/databases/d'); + assert.strictEqual( + pool2.config.connectionString, + 'projects/p/instances/i/databases/d', + ); + }); + + it('should acquire client via connect() promise and callback with 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'); + await client.release(); + assert.strictEqual(client.isConnected, false); + await pool.end(); + }); + + 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()); + }); + }); + + 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.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', + }); + void pool.query('SELECT 1', (err, res) => { + assert.strictEqual(err, null); + assert.strictEqual(res?.command, 'SELECT'); + void pool.end().then(() => done()); + }); + }); + + 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()); + }); + }); + + 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 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; + + const q = new Query(''); + void q.on('error', () => { + errorEventEmitted = true; + }); + + 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); + }); + }); + + assert.strictEqual( + errorEventEmitted, + false, + 'error event should not be emitted when callback is provided', + ); + await pool.end(); + }); + + 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', + ); + } + }); + + 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(); + }; + 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()); + }); + }); + + 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()); + }); + 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(); + }); + void pool.query(q).catch(() => {}); + }); +}); diff --git a/handwritten/spanner-driver/test/unit/query_test.ts b/handwritten/spanner-driver/test/unit/query_test.ts new file mode 100644 index 00000000000..6bd9664ee68 --- /dev/null +++ b/handwritten/spanner-driver/test/unit/query_test.ts @@ -0,0 +1,118 @@ +// 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 {Query} from '../../src/lib/query.js'; + +describe('Query Class', () => { + it('should initialize Query with text string and positional values', () => { + const q = new Query('SELECT $1', [42]); + assert.strictEqual(q.text, 'SELECT $1'); + assert.deepStrictEqual(q.values, [42]); + }); + + it('should initialize Query from QueryConfig object', () => { + const q = new Query({ + text: 'SELECT * FROM users WHERE status = $1', + values: ['ACTIVE'], + rowMode: 'array', + }); + assert.strictEqual(q.text, 'SELECT * FROM users WHERE status = $1'); + assert.deepStrictEqual(q.values, ['ACTIVE']); + assert.strictEqual(q.rowMode, 'array'); + }); + + it('should handle null argument safely in Query constructor', () => { + // @ts-expect-error Testing runtime null text argument + const q = new Query(null); + assert.strictEqual(q.text, null); + assert.strictEqual(q.values, undefined); + }); + + it('should copy properties when constructed from existing Query instance', () => { + const orig = new Query('SELECT 1', [10]); + const q = new Query(orig); + assert.strictEqual(q.text, 'SELECT 1'); + assert.deepStrictEqual(q.values, [10]); + }); + + it('should allow overriding values and callback when constructed from existing Query instance', () => { + const orig = new Query('SELECT $1'); + const cb = () => {}; + const q = new Query(orig, [99], cb); + assert.strictEqual(q.text, 'SELECT $1'); + assert.deepStrictEqual(q.values, [99]); + assert.strictEqual(q.callback, cb); + }); + + it('should handle callback argument overload correctly', () => { + const callbackFn = () => {}; + const q = new Query('SELECT 1', callbackFn); + assert.strictEqual(q.text, 'SELECT 1'); + assert.strictEqual(q.values, undefined); + assert.strictEqual(q.callback, callbackFn); + }); + + it('should resolve thenable promise on .then()', async () => { + const q = new Query<{rowCount: number}>('SELECT 1'); + q.setPromise(Promise.resolve({rowCount: 1})); + const res = await q; + assert.strictEqual(res.rowCount, 1); + }); + + it('should clear promiseResolver after setPromise is called to prevent memory retention on multiple calls', async () => { + const q = new Query<{rowCount: number}>('SELECT 1'); + assert.notStrictEqual( + (q as unknown as {promiseResolver: unknown}).promiseResolver, + undefined, + ); + q.setPromise(Promise.resolve({rowCount: 1})); + assert.strictEqual( + (q as unknown as {promiseResolver: unknown}).promiseResolver, + undefined, + ); + q.setPromise(Promise.resolve({rowCount: 2})); + const res = await q; + assert.strictEqual(res.rowCount, 2); + }); + + it('should reject thenable promise on .catch()', async () => { + const q = new Query('SELECT 1'); + q.setPromise(Promise.reject(new Error('Query failed'))); + try { + await q; + assert.fail('Should have rejected'); + } catch (err: unknown) { + assert.strictEqual((err as Error).message, 'Query failed'); + } + }); + + it('should invoke .finally() callback', async () => { + const q = new Query<{rowCount: number}>('SELECT 1'); + q.setPromise(Promise.resolve({rowCount: 1})); + let finallyInvoked = false; + await q.finally(() => { + finallyInvoked = true; + }); + assert.strictEqual(finallyInvoked, true); + }); + + it('should not throw TypeError when calling catch() or then() on a newly constructed Query', () => { + const q = new Query('SELECT 1'); + assert.doesNotThrow(() => { + q.catch(() => {}); + }); + }); +});