From eba4de2fce53e2768a5dde8173b5fdcdc40e28f9 Mon Sep 17 00:00:00 2001 From: yoshi-code-bot <70984784+yoshi-code-bot@users.noreply.github.com> Date: Thu, 30 Jul 2026 04:35:22 -0700 Subject: [PATCH] chore(spanner-driver): sync spannerlib-node code --- .../spannerlib-node/BUILD_AND_RELEASE.md | 54 + .../spanner-driver/spannerlib-node/README.md | 55 + .../spannerlib-node/binding.gyp | 68 + .../spannerlib-node/eslint.config.cjs | 15 + .../spannerlib-node/eslint.ignores.cjs | 1 + .../spannerlib-node/package.json | 64 + .../scripts/build-shared-lib.sh | 57 + .../scripts/fix-extensions.cjs | 42 + .../spannerlib-node/shared/build-binaries.sh | 116 ++ .../shared/build-java-darwin-aarch64.sh | 3 + .../spannerlib-node/shared/shared_lib.go | 249 +++ .../spannerlib-node/shared/shared_lib_test.go | 675 ++++++++ .../spannerlib-node/src/cpp/addon.cc | 672 ++++++++ .../spannerlib-node/src/ffi/utils.ts | 127 ++ .../spannerlib-node/src/index.ts | 29 + .../spannerlib-node/src/lib/connection.ts | 250 +++ .../spannerlib-node/src/lib/pool.ts | 90 + .../spannerlib-node/src/lib/rows.ts | 256 +++ .../spannerlib-node/src/lib/spannerlib.ts | 123 ++ .../spannerlib-node/test/connection.test.ts | 209 +++ .../spannerlib-node/test/index.test.ts | 36 + .../test/mockserver/connection.test.ts | 730 ++++++++ .../test/mockserver/mockspanner.ts | 1479 +++++++++++++++++ .../test/mockserver/pool.test.ts | 51 + .../spannerlib-node/test/pool.test.ts | 77 + .../spannerlib-node/test/rows.test.ts | 468 ++++++ .../spannerlib-node/tsconfig.cjs.json | 11 + .../spannerlib-node/tsconfig.json | 13 + 28 files changed, 6020 insertions(+) create mode 100644 handwritten/spanner-driver/spannerlib-node/BUILD_AND_RELEASE.md create mode 100644 handwritten/spanner-driver/spannerlib-node/README.md create mode 100644 handwritten/spanner-driver/spannerlib-node/binding.gyp create mode 100644 handwritten/spanner-driver/spannerlib-node/eslint.config.cjs create mode 100644 handwritten/spanner-driver/spannerlib-node/eslint.ignores.cjs create mode 100644 handwritten/spanner-driver/spannerlib-node/package.json create mode 100755 handwritten/spanner-driver/spannerlib-node/scripts/build-shared-lib.sh create mode 100644 handwritten/spanner-driver/spannerlib-node/scripts/fix-extensions.cjs create mode 100755 handwritten/spanner-driver/spannerlib-node/shared/build-binaries.sh create mode 100755 handwritten/spanner-driver/spannerlib-node/shared/build-java-darwin-aarch64.sh create mode 100644 handwritten/spanner-driver/spannerlib-node/shared/shared_lib.go create mode 100644 handwritten/spanner-driver/spannerlib-node/shared/shared_lib_test.go create mode 100644 handwritten/spanner-driver/spannerlib-node/src/cpp/addon.cc create mode 100644 handwritten/spanner-driver/spannerlib-node/src/ffi/utils.ts create mode 100644 handwritten/spanner-driver/spannerlib-node/src/index.ts create mode 100644 handwritten/spanner-driver/spannerlib-node/src/lib/connection.ts create mode 100644 handwritten/spanner-driver/spannerlib-node/src/lib/pool.ts create mode 100644 handwritten/spanner-driver/spannerlib-node/src/lib/rows.ts create mode 100644 handwritten/spanner-driver/spannerlib-node/src/lib/spannerlib.ts create mode 100644 handwritten/spanner-driver/spannerlib-node/test/connection.test.ts create mode 100644 handwritten/spanner-driver/spannerlib-node/test/index.test.ts create mode 100644 handwritten/spanner-driver/spannerlib-node/test/mockserver/connection.test.ts create mode 100644 handwritten/spanner-driver/spannerlib-node/test/mockserver/mockspanner.ts create mode 100644 handwritten/spanner-driver/spannerlib-node/test/mockserver/pool.test.ts create mode 100644 handwritten/spanner-driver/spannerlib-node/test/pool.test.ts create mode 100644 handwritten/spanner-driver/spannerlib-node/test/rows.test.ts create mode 100644 handwritten/spanner-driver/spannerlib-node/tsconfig.cjs.json create mode 100644 handwritten/spanner-driver/spannerlib-node/tsconfig.json diff --git a/handwritten/spanner-driver/spannerlib-node/BUILD_AND_RELEASE.md b/handwritten/spanner-driver/spannerlib-node/BUILD_AND_RELEASE.md new file mode 100644 index 000000000000..c0b61724f786 --- /dev/null +++ b/handwritten/spanner-driver/spannerlib-node/BUILD_AND_RELEASE.md @@ -0,0 +1,54 @@ +# Node.js Spanner Wrapper Build and Release Architecture + +This document describes the compilation pipeline, dual-publishing methodology, and native bridge linking process for the `spannerlib-node` driver. + +## The 3-Layer Pipeline + +Bridging JavaScript to the native Go SDK involves a sequential compilation pipeline mapping V8 types to Go-compatible memory pointers across three separate layers: + +``` + [TypeScript Source] ---> (Babel/TSC) ---> [ESM & CJS JavaScript] + | + (node-gyp bridge) + v + [C++ Native Addon] + | + (cgo linker bridge) + v + [Go Shared Library] +``` + +## Compilation Phases + +### Phase 1: Compiling the Go Shared Library (CGO Link) + +Before building the Node.js Addon, the underlying Go codebase must be compiled into an object format that C/C++ can link against. +* **Trigger:** Executed via `npm run build:go` which runs `bash scripts/build-shared-lib.sh`. +* **Action:** The build script invokes the Go compiler with the `-buildmode=c-shared` flag, targeting the primary C-shared entry point located at [shared_lib.go](../../shared/shared_lib.go). +* **Outputs:** Generates a platform-specific native shared library (e.g., `libspannerlib.dylib` on macOS, `.so` on Linux, `.dll` on Windows) along with the corresponding C header file (`libspannerlib.h`). Both files are placed into the `spannerlib/shared/` directory. + +### Phase 2: Compiling the Native Bridge (node-gyp) + +Once the Go shared library is generated, the Node.js C++ wrapper is compiled using `node-gyp` to map V8 engine objects into Go pointers. +* **Trigger:** Executed as part of `npm run build` which invokes `node-gyp rebuild`. +* **Action:** Reads the `gyp` build instructions in [binding.gyp](./binding.gyp) to locate the Go header files, and dynamically links the bridge against the generated Go shared object. It compiles the bridge source file [addon.cc](./src/cpp/addon.cc) using the local OS C++ compiler toolchain (e.g., Clang on macOS, GCC on Linux, MSVC on Windows). +* **Output:** Generates the native Node.js binary file at `build/Release/spanner_napi.node`. +* **Post-build Link Patch (macOS Only):** To ensure portability on macOS without requiring root or global library installs, `npm run postbuild` invokes the `install_name_tool`. This command alters the dynamic linker search path in the `.node` file to use `@loader_path/libspannerlib.dylib`, ensuring Node.js locates the Go dynamic library relatively from the compiled C++ bridge binary path. + +### Phase 3: TypeScript Compilation & Dual-Publishing (ESM / CJS) + +Finally, the TypeScript layer (which handles user-facing API classes, JavaScript's `FinalizationRegistry` garbage collection mapping, and Protobuf serialization) is compiled for consumer consumption. The build is configured to output both **ES Modules (ESM)** and **CommonJS (CJS)** simultaneously, ensuring compatibility across modern and legacy Node.js environments. +* **Trigger:** Executed via `npm run compile`. +* **Action:** + 1. **ESM Setup:** `npm run compile:esm` runs the standard `tsc` (TypeScript Compiler) using the primary [tsconfig.json](./tsconfig.json) file. Outputs ES6 modules into `build/esm/`. + 2. **CJS Setup:** `npm run compile:cjs` compiles the codebase for legacy support using [tsconfig.cjs.json](./tsconfig.cjs.json) and pipes the JS output through `@babel/cli` to translate modern `import`/`export` keywords into standard CommonJS `require()` bindings. + 3. **Extension Fixer:** Because Node.js requires explicit file extensions when using ESM but Babel strips them or expects `.cjs` in CommonJS modes, the post-compilation script `node scripts/fix-extensions.cjs` rewrites the internal import paths across the `build/cjs/` folder to use `.cjs` extensions. +* **Output:** Generates two parallel directory trees under `build/esm` and `build/cjs`, making the distributed npm package natively dual-consumable using the `exports` configuration in `package.json`. + +## End-to-End Local Builds + +To run the entire pipeline end-to-end and generate a fully runnable local build, invoke the top-level script: +```bash +npm run build +``` +This builds the underlying Go shared library, links the C++ bridge layer via `node-gyp`, patches dynamic linker paths, and outputs the final dual ESM/CJS JavaScript distributions. diff --git a/handwritten/spanner-driver/spannerlib-node/README.md b/handwritten/spanner-driver/spannerlib-node/README.md new file mode 100644 index 000000000000..b87dea93231e --- /dev/null +++ b/handwritten/spanner-driver/spannerlib-node/README.md @@ -0,0 +1,55 @@ +# Node-API Wrapper for Spanner Shared Library + +This package provides a high-performance Node-API (N-API) bridge to the Go-based Spanner shared library. It offers superior stability and performance compared to traditional FFI approaches. + +## Prerequisites + +- Node.js >= 20.0.0 +- Go compiler (to build the underlying shared library, if not pre-built) +- C++ toolchain (GCC/Clang or MSVC) + +## Installation & Building + +To build the native addon and compile the driver for development, run: + +```bash +npm install +``` + +For a comprehensive architectural breakdown of the 3-layer compilation pipeline (Go Link, C++ node-gyp bridge, and Dual-publishing ESM/CJS), refer to the [BUILD_AND_RELEASE.md](file:///Users/gargsurbhi/Work/Github/go-sql-spanner/spannerlib/wrappers/spannerlib-node/BUILD_AND_RELEASE.md) documentation. + +## Usage + +```javascript +const { Pool, Connection } = require('spannerlib-node'); + +async function run() { + const pool = await Pool.create(connectionString); + + const conn = await pool.createConnection(); + const rows = await conn.executeSql('SELECT 1'); + + while (await rows.next()) { + // process rows + } + + await rows.close(); + await conn.close(); + await pool.close(); +} +``` + +## Architecture + +The wrapper consists of: +1. **`src/cpp/addon.cc`**: C++ Node-API bridge that handles thread boundaries and type conversions between V8 and C. +2. **`src/ffi/utils.ts`**: Helper functions to invoke native methods asynchronously using Promises. +3. **`src/lib/`**: JavaScript classes (`Pool`, `Connection`, `Rows`) that provide a clean object-oriented interface. + +### Component Interaction + +The wrapper operates by executing calls through the low-level native bridge (`addon.cc`). The user-facing classes (`Pool`, `Connection`, `Rows`) in the `lib` folder map execution requests into Protobuf payloads and pass them to the bridge via the asynchronous dispatcher in `utils.ts`. Each successful execution returns memory ID handles that are tracked inside the singleton state registry (`spannerlib.ts`) to link native garbage collection triggers. + +### Component Interaction & Memory Management + +When a JavaScript object (like a `Pool` or `Connection`) is created, it holds an ID referencing a pinned Go object in memory. The `spannerLib` singleton maintains a **`FinalizationRegistry`**. This registry allows Node.js to listen for when the JavaScript object is garbage collected. When GC occurs, the registry automatically triggers a cleanup call to the native layer to release the corresponding Go object, preventing native memory leaks even if the developer forgets to call `.close()` explicitly. diff --git a/handwritten/spanner-driver/spannerlib-node/binding.gyp b/handwritten/spanner-driver/spannerlib-node/binding.gyp new file mode 100644 index 000000000000..98f81feb2029 --- /dev/null +++ b/handwritten/spanner-driver/spannerlib-node/binding.gyp @@ -0,0 +1,68 @@ +{ + 'targets': [ + { + 'target_name': 'spanner_napi', + 'sources': [ 'src/cpp/addon.cc' ], + 'include_dirs': [ + '=20.0.0" + }, + "scripts": { + "build:go": "bash scripts/build-shared-lib.sh", + "build": "npm run build:go && node-gyp rebuild && npm run compile", + "postbuild": "node -e \"if (process.platform === 'darwin') require('child_process').execSync('install_name_tool -change libspanner.dylib @loader_path/libspanner.dylib ./build/Release/spanner_napi.node')\"", + "compile:esm": "tsc -p .", + "compile:cjs": "tsc -p ./tsconfig.cjs.json && babel build/cjs --out-dir build/cjs --out-file-extension .cjs && node scripts/fix-extensions.cjs", + "compile": "npm run compile:esm && npm run compile:cjs", + "test:esm": "mocha build/esm/test/**/*.js", + "test:cjs": "mocha build/cjs/test/**/*.cjs", + "test": "npm run test:esm && npm run test:cjs", + "lint": "gts lint", + "clean": "gts clean", + "fix": "gts fix", + "prepare": "npm run compile", + "pretest": "npm run compile" + }, + "files": [ + "build/esm/src", + "build/cjs/src", + "build/Release/*.node" + ], + "dependencies": { + "node-addon-api": "^8.0.0", + "bindings": "^1.5.0", + "@google-cloud/spanner": "^8.7.1" + }, + "devDependencies": { + "mocha": "^11.0.1", + "typescript": "^6.0.0", + "@types/node": "^24.0.0", + "@types/bindings": "^1.5.0", + "@types/mocha": "^10.0.6", + "@babel/core": "^8.0.0", + "@babel/cli": "^8.0.0", + "sinon": "^22.0.0", + "@types/sinon": "^21.0.0", + "gts": "^7.0.0", + "@grpc/grpc-js": "^1.12.0", + "@grpc/proto-loader": "^0.8.0" + }, + "gypfile": false +} diff --git a/handwritten/spanner-driver/spannerlib-node/scripts/build-shared-lib.sh b/handwritten/spanner-driver/spannerlib-node/scripts/build-shared-lib.sh new file mode 100755 index 000000000000..04b08e31482e --- /dev/null +++ b/handwritten/spanner-driver/spannerlib-node/scripts/build-shared-lib.sh @@ -0,0 +1,57 @@ +#!/bin/bash +set -e + +# 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. + +# Builds the shared library and places it in the shared directory. +# This script handles OS detection to use the correct file extension. + +log() { + echo "[$(date +'%Y-%m-%d %H:%M:%S')] $1" +} + +log "Starting Spannerlib Shared Library Build for Node Wrapper..." + +# Resolve absolute paths +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SHARED_LIB_DIR="$(cd "$SCRIPT_DIR/../shared" && pwd)" + +log "Script Directory: $SCRIPT_DIR" +log "Shared Lib Directory: $SHARED_LIB_DIR" + +# Auto-detect OS +case "$(uname -s)" in + Linux*) OS="Linux";; + Darwin*) OS="macOS";; + CYGWIN*|MINGW*|MSYS*) OS="Windows";; + *) OS="Unknown";; +esac +log "Auto-detected OS: $OS" + +if [ "$OS" == "macOS" ]; then + echo "Building for macOS..." + go build -C "$SHARED_LIB_DIR" -o libspanner.dylib -buildmode=c-shared shared_lib.go +elif [ "$OS" == "Linux" ]; then + echo "Building for Linux..." + go build -C "$SHARED_LIB_DIR" -o libspanner.so -buildmode=c-shared shared_lib.go +elif [ "$OS" == "Windows" ]; then + echo "Building for Windows..." + go build -C "$SHARED_LIB_DIR" -o libspanner.dll -buildmode=c-shared shared_lib.go +else + echo "Unsupported operating system: $OS" + exit 1 +fi + +echo "Build complete." diff --git a/handwritten/spanner-driver/spannerlib-node/scripts/fix-extensions.cjs b/handwritten/spanner-driver/spannerlib-node/scripts/fix-extensions.cjs new file mode 100644 index 000000000000..9fd1428d6e4a --- /dev/null +++ b/handwritten/spanner-driver/spannerlib-node/scripts/fix-extensions.cjs @@ -0,0 +1,42 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +const fs = require('fs'); +const path = require('path'); + +const cjsDir = path.join(__dirname, '../build/cjs'); + +function traverseDir(dir) { + fs.readdirSync(dir).forEach(file => { + const fullPath = path.join(dir, file); + if (fs.statSync(fullPath).isDirectory()) { + traverseDir(fullPath); + } else if (fullPath.endsWith('.cjs')) { + let content = fs.readFileSync(fullPath, 'utf8'); + // Replace require paths starting with . / or .. / and ending with .js + content = content.replace(/require\(['"]((\.|\.\.)\/[^'"]+)\.js['"]\)/g, "require('$1.cjs')"); + // Fix import.meta.url syntax error in CommonJS + content = content.replace(/import\.meta\.url/g, '""'); + fs.writeFileSync(fullPath, content); + } else if (fullPath.endsWith('.js')) { + // Delete the original .js file generated by tsc + fs.unlinkSync(fullPath); + } + }); +} + +if (fs.existsSync(cjsDir)) { + traverseDir(cjsDir); + console.log('Fixed extensions in .cjs files and removed .js files in build/cjs'); +} diff --git a/handwritten/spanner-driver/spannerlib-node/shared/build-binaries.sh b/handwritten/spanner-driver/spannerlib-node/shared/build-binaries.sh new file mode 100755 index 000000000000..0d1db8eb6344 --- /dev/null +++ b/handwritten/spanner-driver/spannerlib-node/shared/build-binaries.sh @@ -0,0 +1,116 @@ +#!/bin/bash +set -e + +# Builds the shared library binaries and copies the binaries to OS/arch specific folders. +# Binaries can be built for linux/x64, linux/arm64, darwin/arm64, darwin/amd64, and windows/x64. +# +# Environment Variables: +# SKIP_MACOS: If set, will skip all macOS builds. +# SKIP_LINUX: If set, will skip all Linux builds. +# SKIP_WINDOWS: If set, will skip all Windows builds. +# SKIP_LINUX_CROSS_COMPILE: If set, will skip the Linux x64 cross-compile (useful when running on Linux). +# BUILD_MACOS_AMD64: If set, will build for macOS AMD64. +# BUILD_LINUX_ARM64: If set, will build for Linux ARM64. +# CC_LINUX_ARM64: Compiler for Linux ARM64 (default: aarch64-linux-gnu-gcc). + +log() { + echo "[$(date +'%Y-%m-%d %H:%M:%S')] $1" +} + +build_artifact() { + local os=$1 + local arch=$2 + local output_dir=$3 + local output_file=$4 + local cc=$5 + + log "Building for $os/$arch..." + mkdir -p "$output_dir" + + if [ -n "$cc" ]; then + export CC="$cc" + else + unset CC + fi + + GOOS="$os" GOARCH="$arch" CGO_ENABLED=1 go build -o "$output_dir/$output_file" -buildmode=c-shared shared_lib.go + log "Successfully built $output_dir/$output_file" +} + +# Detect current OS to set smart defaults +CURRENT_OS=$(uname -s) + +log "Current OS: $CURRENT_OS" +log "Skip macOS: ${SKIP_MACOS:-false}" +log "Skip Linux: ${SKIP_LINUX:-false}" +log "Skip Linux cross compile: ${SKIP_LINUX_CROSS_COMPILE:-false}" +log "Skip Windows: ${SKIP_WINDOWS:-false}" + +# --- MacOS Builds --- +if [ -z "$SKIP_MACOS" ]; then + # MacOS ARM64 (Apple Silicon) + build_artifact "darwin" "arm64" "binaries/osx-arm64" "spannerlib.dylib" "" + + # MacOS AMD64 (Intel) + if [ -n "$BUILD_MACOS_AMD64" ]; then + build_artifact "darwin" "amd64" "binaries/osx-x64" "spannerlib.dylib" "" + fi +fi + +# --- Linux Builds --- +if [ -z "$SKIP_LINUX" ] || [ -z "$SKIP_LINUX_CROSS_COMPILE" ]; then + # Linux x64 + # Logic: If we are on Linux, we prefer native build. + # If we are NOT on Linux (e.g. Mac), we try cross-compile unless skipped. + + if [ "$CURRENT_OS" == "Linux" ]; then + # Native Linux build + build_artifact "linux" "amd64" "binaries/linux-x64" "spannerlib.so" "" + else + # Cross-compile for Linux x64 (e.g. from Mac) + if [ -z "$SKIP_LINUX_CROSS_COMPILE" ]; then + # Check for cross-compiler + if command -v x86_64-unknown-linux-gnu-gcc >/dev/null 2>&1; then + build_artifact "linux" "amd64" "binaries/linux-x64" "spannerlib.so" "x86_64-unknown-linux-gnu-gcc" + else + log "WARNING: x86_64-unknown-linux-gnu-gcc not found. Skipping Linux x64 cross-compile." + fi + elif [ -z "$SKIP_LINUX" ]; then + # Fallback to standard build if cross-compile explicitly skipped but Linux not skipped + # This might fail if no suitable compiler is found, but we'll try. + build_artifact "linux" "amd64" "binaries/linux-x64" "spannerlib.so" "" + fi + fi + + # Linux ARM64 + if [ -n "$BUILD_LINUX_ARM64" ]; then + CC_ARM64=${CC_LINUX_ARM64:-aarch64-linux-gnu-gcc} + if command -v "$CC_ARM64" >/dev/null 2>&1; then + build_artifact "linux" "arm64" "binaries/linux-arm64" "spannerlib.so" "$CC_ARM64" + else + log "WARNING: $CC_ARM64 not found. Skipping Linux ARM64 build." + fi + fi +fi + +# --- Windows Builds --- +if [ -z "$SKIP_WINDOWS" ]; then + # Windows x64 + CC_WIN="x86_64-w64-mingw32-gcc" + + # If running on Windows (Git Bash/MSYS2), we might not need the cross-compiler prefix or it might be different. + # But usually 'gcc' on Windows targets Windows. + if [[ "$CURRENT_OS" == *"MINGW"* ]] || [[ "$CURRENT_OS" == *"CYGWIN"* ]] || [[ "$CURRENT_OS" == *"MSYS"* ]]; then + # Native Windows build + build_artifact "windows" "amd64" "binaries/win-x64" "spannerlib.dll" "" + else + # Cross-compile for Windows + if command -v "$CC_WIN" >/dev/null 2>&1; then + build_artifact "windows" "amd64" "binaries/win-x64" "spannerlib.dll" "$CC_WIN" + else + log "WARNING: $CC_WIN not found. Skipping Windows x64 build." + fi + fi +fi + +log "Build process completed." diff --git a/handwritten/spanner-driver/spannerlib-node/shared/build-java-darwin-aarch64.sh b/handwritten/spanner-driver/spannerlib-node/shared/build-java-darwin-aarch64.sh new file mode 100755 index 000000000000..3b1ce2167d86 --- /dev/null +++ b/handwritten/spanner-driver/spannerlib-node/shared/build-java-darwin-aarch64.sh @@ -0,0 +1,3 @@ +go build -o spannerlib.so -buildmode=c-shared shared_lib.go +mkdir -p ../wrappers/spannerlib-java/src/main/resources/darwin-aarch64 +cp spannerlib.so ../wrappers/spannerlib-java/src/main/resources/darwin-aarch64/libspanner.dylib diff --git a/handwritten/spanner-driver/spannerlib-node/shared/shared_lib.go b/handwritten/spanner-driver/spannerlib-node/shared/shared_lib.go new file mode 100644 index 000000000000..2d4a5aef96d4 --- /dev/null +++ b/handwritten/spanner-driver/spannerlib-node/shared/shared_lib.go @@ -0,0 +1,249 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import "C" +import ( + "context" + "runtime" + "sync" + "sync/atomic" + "time" + "unsafe" + + "spannerlib/lib" +) + +// An (empty) main function is required for C libraries. +func main() {} + +var ( + pinners = sync.Map{} + pinnerIdx atomic.Int64 +) + +// Release releases (unpins) a previously pinned message. Pinners are created and +// returned for each function call in this library, and it is the responsibility of +// the caller to call Release when it is done with the data that was returned. +// Note that 'done' can also mean 'the data has been copied into memory that is +// managed by the caller'. +// +//export Release +func Release(pinnerId int64) int32 { + if pinnerId <= 0 { + return 0 + } + val, ok := pinners.LoadAndDelete(pinnerId) + if !ok { + return 1 + } + pinner := val.(*runtime.Pinner) + pinner.Unpin() + return 0 +} + +// pin pins the memory location pointed to by the result of the given message. +// This prevents the Go runtime from moving or garbage collecting this memory. +// The returned pinner ID must be used to call Release when the caller is done +// with the message. +func pin(msg *lib.Message) (int64, int32, int64, int32, unsafe.Pointer) { + if msg.Length() == 0 { + return 0, msg.Code, msg.ObjectId, 0, nil + } + pinner := &runtime.Pinner{} + pinner.Pin(&(msg.Res[0])) + idx := pinnerIdx.Add(1) + pinners.Store(idx, pinner) + return idx, msg.Code, msg.ObjectId, msg.Length(), msg.ResPointer() +} + +// CreatePool creates a pool of database connections. A Pool is equivalent to a *sql.DB. +// All connections that are created from a pool share the same underlying Spanner client. +// +//export CreatePool +func CreatePool(userAgentSuffix, connectionString string) (int64, int32, int64, int32, unsafe.Pointer) { + // TODO: Allow a user of the shared library to specify a custom context, for example with a custom timeout. + ctx := context.Background() + msg := lib.CreatePool(ctx, userAgentSuffix, connectionString) + return pin(msg) +} + +// ClosePool closes a previously opened Pool. All connections in the pool are also closed. +// +//export ClosePool +func ClosePool(id int64) (int64, int32, int64, int32, unsafe.Pointer) { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + msg := lib.ClosePool(ctx, id) + return pin(msg) +} + +// CreateConnection creates or borrows a connection from a previously created pool. +// Note that as Spanner does not really use a 'connection-based' API, creating a +// connection is a relatively cheap operation. It does not physically create a new +// gRPC channel or any other physical connection to Spanner, and it also does not +// create a server-side session. Instead, all session state is stored in the client. +// +//export CreateConnection +func CreateConnection(poolId int64) (int64, int32, int64, int32, unsafe.Pointer) { + ctx := context.Background() + msg := lib.CreateConnection(ctx, poolId) + return pin(msg) +} + +// CloseConnection closes a previously opened connection and releases all resources +// associated with the connection. +// +//export CloseConnection +func CloseConnection(poolId, connId int64) (int64, int32, int64, int32, unsafe.Pointer) { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + msg := lib.CloseConnection(ctx, poolId, connId) + return pin(msg) +} + +// WriteMutations writes an array of mutations to Spanner. The mutations are buffered in +// the current read/write transaction if the connection currently has a read/write transaction. +// The mutations are applied to the database in a new read/write transaction that is automatically +// committed if the connection currently does not have a transaction. +// +// The function returns an error if the connection is currently in a read-only transaction. +// +// The mutationsBytes must be an encoded BatchWriteRequest_MutationGroup protobuf object. +// +//export WriteMutations +func WriteMutations(poolId, connectionId int64, mutationsBytes []byte) (int64, int32, int64, int32, unsafe.Pointer) { + ctx := context.Background() + msg := lib.WriteMutations(ctx, poolId, connectionId, mutationsBytes) + return pin(msg) +} + +// Execute executes a SQL statement on the given connection. +// The return type is an identifier for a Rows object. This identifier can be used to +// call the functions Metadata and Next to get respectively the metadata of the result +// and the next row of results. +// +// TODO: This function should also be able to return a ResultSet containing the first N rows, the metadata, and the stats. +// +//export Execute +func Execute(poolId, connectionId int64, statement []byte) (int64, int32, int64, int32, unsafe.Pointer) { + ctx := context.Background() + msg := lib.Execute(ctx, poolId, connectionId, statement) + return pin(msg) +} + +// ExecuteBatch executes a batch of statements on the given connection. The statements must all be either DML or DDL +// statements. Mixing DML and DDL in a batch is not supported. Executing queries in a batch is also not supported. +// The batch will use the current transaction on the given connection, or execute as a single auto-commit statement +// if the connection does not have a transaction. +// +//export ExecuteBatch +func ExecuteBatch(poolId, connectionId int64, statements []byte) (int64, int32, int64, int32, unsafe.Pointer) { + ctx := context.Background() + msg := lib.ExecuteBatch(ctx, poolId, connectionId, statements) + return pin(msg) +} + +// Metadata returns the metadata of a Rows object. +// +//export Metadata +func Metadata(poolId, connId, rowsId int64) (int64, int32, int64, int32, unsafe.Pointer) { + ctx := context.Background() + msg := lib.Metadata(ctx, poolId, connId, rowsId) + return pin(msg) +} + +// ResultSetStats returns the statistics for a statement that has been executed. This includes +// the number of rows affected in case of a DML statement. +// Statistics are only available once all rows have been consumed. +// +//export ResultSetStats +func ResultSetStats(poolId, connId, rowsId int64) (int64, int32, int64, int32, unsafe.Pointer) { + ctx := context.Background() + msg := lib.ResultSetStats(ctx, poolId, connId, rowsId) + return pin(msg) +} + +// NextResultSet returns the metadata of the next result set of the given Rows object, or an empty message +// if the Rows object does not contain more result sets. +// +//export NextResultSet +func NextResultSet(poolId, connId, rowsId int64) (int64, int32, int64, int32, unsafe.Pointer) { + ctx := context.Background() + msg := lib.NextResultSet(ctx, poolId, connId, rowsId) + return pin(msg) +} + +// Next returns the next row in a Rows object. The returned message contains a protobuf +// ListValue that contains all the columns of the row. The message is empty if there are +// no more rows in the Rows object. +// +//export Next +func Next(poolId, connId, rowsId int64, numRows int32, encodeRowOption int32) (int64, int32, int64, int32, unsafe.Pointer) { + ctx := context.Background() + // TODO: Implement support for: + // 1. Fetching more than one row at a time. + // 2. Specifying the return type (e.g. proto, struct, ...) + msg := lib.Next(ctx, poolId, connId, rowsId) + return pin(msg) +} + +// CloseRows closes and cleans up all memory held by a Rows object. This must be called +// when the application is done with the Rows object. +// +//export CloseRows +func CloseRows(poolId, connId, rowsId int64) (int64, int32, int64, int32, unsafe.Pointer) { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + msg := lib.CloseRows(ctx, poolId, connId, rowsId) + return pin(msg) +} + +// BeginTransaction begins a new transaction on the given connection. +// The txOpts byte slice contains a serialized protobuf TransactionOptions object. +// +//export BeginTransaction +func BeginTransaction(poolId, connectionId int64, txOpts []byte) (int64, int32, int64, int32, unsafe.Pointer) { + ctx := context.Background() + msg := lib.BeginTransaction(ctx, poolId, connectionId, txOpts) + return pin(msg) +} + +// Commit commits the current transaction on a connection. All transactions must be +// either committed or rolled back, including read-only transactions. This to ensure +// that all resources that are held by a transaction are cleaned up. +// +//export Commit +func Commit(poolId, connectionId int64) (int64, int32, int64, int32, unsafe.Pointer) { + ctx := context.Background() + msg := lib.Commit(ctx, poolId, connectionId) + return pin(msg) +} + +// Rollback rolls back a previously started transaction. All transactions must be either +// committed or rolled back, including read-only transactions. This to ensure that +// all resources that are held by a transaction are cleaned up. +// +// Spanner does not require read-only transactions to be committed or rolled back, but +// this library requires that all transactions are committed or rolled back to clean up +// all resources. Commit and Rollback are semantically the same for read-only transactions +// on Spanner, and both functions just close the transaction. +// +//export Rollback +func Rollback(poolId, connectionId int64) (int64, int32, int64, int32, unsafe.Pointer) { + ctx := context.Background() + msg := lib.Rollback(ctx, poolId, connectionId) + return pin(msg) +} diff --git a/handwritten/spanner-driver/spannerlib-node/shared/shared_lib_test.go b/handwritten/spanner-driver/spannerlib-node/shared/shared_lib_test.go new file mode 100644 index 000000000000..308b6bdebca6 --- /dev/null +++ b/handwritten/spanner-driver/spannerlib-node/shared/shared_lib_test.go @@ -0,0 +1,675 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "fmt" + "reflect" + "testing" + "unsafe" + + "cloud.google.com/go/spanner/admin/database/apiv1/databasepb" + "cloud.google.com/go/spanner/apiv1/spannerpb" + "github.com/googleapis/go-sql-spanner/testutil" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/structpb" + "spannerlib/api" +) + +// The tests in this file only verify the happy flow to ensure that everything compiles. +// Corner cases are tested in the lib and api packages. + +func TestCreatePool(t *testing.T) { + t.Parallel() + + server, teardown := setupMockServer(t) + defer teardown() + dsn := fmt.Sprintf("%s/projects/p/instances/i/databases/d?useplaintext=true", server.Address) + + mem, code, poolId, length, data := CreatePool("test", dsn) + if g, w := mem, int64(0); g != w { + t.Fatalf("CreatePool mem mismatch\n Got: %v\nWant: %v", g, w) + } + if g, w := code, int32(0); g != w { + t.Fatalf("CreatePool result mismatch\n Got: %v\nWant: %v", g, w) + } + if poolId <= int64(0) { + t.Fatalf("poolId mismatch: %v", poolId) + } + if g, w := length, int32(0); g != w { + t.Fatalf("CreatePool length mismatch\n Got: %v\nWant: %v", g, w) + } + if g, w := unsafe.Pointer(nil), data; g != w { + t.Fatalf("CreatePool data mismatch\n Got: %v\nWant: %v", g, w) + } + + _, code, _, _, _ = ClosePool(poolId) + if g, w := code, int32(0); g != w { + t.Fatalf("ClosePool result mismatch\n Got: %v\nWant: %v", g, w) + } +} + +func TestCreateConnection(t *testing.T) { + t.Parallel() + + server, teardown := setupMockServer(t) + defer teardown() + dsn := fmt.Sprintf("%s/projects/p/instances/i/databases/d?useplaintext=true", server.Address) + + _, code, poolId, _, _ := CreatePool("test", dsn) + if g, w := code, int32(0); g != w { + t.Fatalf("CreatePool result mismatch\n Got: %v\nWant: %v", g, w) + } + mem, code, connId, length, data := CreateConnection(poolId) + if g, w := mem, int64(0); g != w { + t.Fatalf("CreateConnection mem mismatch\n Got: %v\nWant: %v", g, w) + } + if g, w := code, int32(0); g != w { + t.Fatalf("CreateConnection result mismatch\n Got: %v\nWant: %v", g, w) + } + if connId <= int64(0) { + t.Fatalf("connId mismatch: %v", poolId) + } + if g, w := length, int32(0); g != w { + t.Fatalf("CreateConnection length mismatch\n Got: %v\nWant: %v", g, w) + } + if g, w := unsafe.Pointer(nil), data; g != w { + t.Fatalf("CreateConnection data mismatch\n Got: %v\nWant: %v", g, w) + } + + _, code, _, _, _ = CloseConnection(poolId, connId) + if g, w := code, int32(0); g != w { + t.Fatalf("CloseConnection result mismatch\n Got: %v\nWant: %v", g, w) + } + _, code, _, _, _ = ClosePool(poolId) + if g, w := code, int32(0); g != w { + t.Fatalf("ClosePool result mismatch\n Got: %v\nWant: %v", g, w) + } +} + +func TestExecute(t *testing.T) { + // This test is intentionally not marked as Parallel, as it checks the number of open memory pointers. + + server, teardown := setupMockServer(t) + defer teardown() + dsn := fmt.Sprintf("%s/projects/p/instances/i/databases/d?useplaintext=true", server.Address) + + _, code, poolId, _, _ := CreatePool("test", dsn) + if g, w := code, int32(0); g != w { + t.Fatalf("CreatePool result mismatch\n Got: %v\nWant: %v", g, w) + } + _, code, connId, _, _ := CreateConnection(poolId) + if g, w := code, int32(0); g != w { + t.Fatalf("CreateConnection result mismatch\n Got: %v\nWant: %v", g, w) + } + + request := &spannerpb.ExecuteSqlRequest{ + // This query returns a result set with one column and two rows. + // The values in the two rows are 1 and 2. + Sql: testutil.SelectFooFromBar, + } + requestBytes, err := proto.Marshal(request) + if err != nil { + t.Fatal(err) + } + // Execute returns a reference to a Rows object, not the actual data. + mem, code, rowsId, length, data := Execute(poolId, connId, requestBytes) + if g, w := mem, int64(0); g != w { + t.Fatalf("Execute mem mismatch\n Got: %v\nWant: %v", g, w) + } + if g, w := code, int32(0); g != w { + t.Fatalf("Execute result mismatch\n Got: %v\nWant: %v", g, w) + } + if rowsId <= int64(0) { + t.Fatalf("rowsId mismatch: %v", rowsId) + } + if g, w := length, int32(0); g != w { + t.Fatalf("Execute length mismatch\n Got: %v\nWant: %v", g, w) + } + if g, w := unsafe.Pointer(nil), data; g != w { + t.Fatalf("Execute data mismatch\n Got: %v\nWant: %v", g, w) + } + + // Get the metadata of the selected rows. + mem, code, _, length, data = Metadata(poolId, connId, rowsId) + // Metadata returns actual data, and should therefore return a memory ID that needs to be released. + if mem == int64(0) { + t.Fatalf("Metadata mem mismatch: %v", mem) + } + if length == int32(0) { + t.Fatalf("Metadata length mismatch: %v", length) + } + // Get a []byte from the pointer to the data and the length. + metadataBytes := reflect.SliceAt(reflect.TypeOf(byte(0)), data, int(length)).Bytes() + metadata := &spannerpb.ResultSetMetadata{} + if err := proto.Unmarshal(metadataBytes, metadata); err != nil { + t.Fatal(err) + } + if g, w := len(metadata.RowType.Fields), 1; g != w { + t.Fatalf("Metadata field count mismatch\n Got: %v\nWant: %v", g, w) + } + if g, w := metadata.RowType.Fields[0].Name, "FOO"; g != w { + t.Fatalf("Metadata field name mismatch\n Got: %v\nWant: %v", g, w) + } + if g, w := metadata.RowType.Fields[0].Type.Code, spannerpb.TypeCode_INT64; g != w { + t.Fatalf("Metadata type code mismatch\n Got: %v\nWant: %v", g, w) + } + // Release the memory. + if g, w := Release(mem), int32(0); g != w { + t.Fatalf("Release() result mismatch\n Got: %v\nWant: %v", g, w) + } + + // Iterate over the rows. + numRows := 0 + for { + mem, code, _, length, data = Next(poolId, connId, rowsId /*numRows = */, 1, int32(api.EncodeRowOptionProto)) + // Next returns an empty message if it is the end of the query results. + if length == 0 { + break + } + numRows++ + // Decode the row. + rowBytes := reflect.SliceAt(reflect.TypeOf(byte(0)), data, int(length)).Bytes() + row := &structpb.ListValue{} + if err := proto.Unmarshal(rowBytes, row); err != nil { + t.Fatal(err) + } + // Release the memory that was held for the row. We can do that as soon as it has + // been copied into a data structure that is maintained by the 'application'. + // The 'application' in this case is the test. + if g, w := Release(mem), int32(0); g != w { + t.Fatalf("Release() result mismatch\n Got: %v\nWant: %v", g, w) + } + // Verify the row data. + if g, w := len(row.GetValues()), 1; g != w { + t.Fatalf("num row values mismatch\n Got: %v\nWant: %v", g, w) + } + if g, w := row.GetValues()[0].GetStringValue(), fmt.Sprintf("%d", numRows); g != w { + t.Fatalf("row values mismatch\n Got: %v\nWant: %v", g, w) + } + } + // The result should contain two rows. + if g, w := numRows, 2; g != w { + t.Fatalf("num rows mismatch\n Got: %v\nWant: %v", g, w) + } + + // Get the ResultSetStats. For queries, this is nil. + mem, code, _, length, data = ResultSetStats(poolId, connId, rowsId) + if g, w := code, int32(0); g != w { + t.Fatalf("ResultSetStats result code mismatch\n Got: %v\nWant: %v", g, w) + } + if g, w := length, int32(0); g != w { + t.Fatalf("ResultSetStats length mismatch\n Got: %v\nWant: %v", g, w) + } + if res := Release(mem); res != 0 { + t.Fatalf("Release() result mismatch\n Got: %v\nWant: %v", res, 0) + } + + _, code, _, _, _ = CloseRows(poolId, connId, rowsId) + if g, w := code, int32(0); g != w { + t.Fatalf("CloseRows result mismatch\n Got: %v\nWant: %v", g, w) + } + _, code, _, _, _ = CloseConnection(poolId, connId) + if g, w := code, int32(0); g != w { + t.Fatalf("CloseConnection result mismatch\n Got: %v\nWant: %v", g, w) + } + _, code, _, _, _ = ClosePool(poolId) + if g, w := code, int32(0); g != w { + t.Fatalf("ClosePool result mismatch\n Got: %v\nWant: %v", g, w) + } + + if g, w := countOpenMemoryPointers(), 0; g != w { + t.Fatalf("countOpenMemoryPointers() result mismatch\n Got: %v\nWant: %v", g, w) + } +} + +func TestExecuteMultiStatement(t *testing.T) { + t.Parallel() + + server, teardown := setupMockServer(t) + defer teardown() + dsn := fmt.Sprintf("%s/projects/p/instances/i/databases/d?useplaintext=true", server.Address) + + _, code, poolId, _, _ := CreatePool("test", dsn) + if g, w := code, int32(0); g != w { + t.Fatalf("CreatePool result mismatch\n Got: %v\nWant: %v", g, w) + } + _, code, connId, _, _ := CreateConnection(poolId) + if g, w := code, int32(0); g != w { + t.Fatalf("CreateConnection result mismatch\n Got: %v\nWant: %v", g, w) + } + + request := &spannerpb.ExecuteSqlRequest{ + // This query returns a result set with one column and two rows. + // The values in the two rows are 1 and 2. + // We execute the query twice using a single SQL string. + Sql: fmt.Sprintf("%s;%s", testutil.SelectFooFromBar, testutil.SelectFooFromBar), + } + requestBytes, err := proto.Marshal(request) + if err != nil { + t.Fatal(err) + } + // Execute returns a reference to a Rows object, not the actual data. + mem, code, rowsId, length, data := Execute(poolId, connId, requestBytes) + if g, w := mem, int64(0); g != w { + t.Fatalf("Execute mem mismatch\n Got: %v\nWant: %v", g, w) + } + if g, w := code, int32(0); g != w { + t.Fatalf("Execute result mismatch\n Got: %v\nWant: %v", g, w) + } + if rowsId <= int64(0) { + t.Fatalf("rowsId mismatch: %v", rowsId) + } + if g, w := length, int32(0); g != w { + t.Fatalf("Execute length mismatch\n Got: %v\nWant: %v", g, w) + } + if g, w := unsafe.Pointer(nil), data; g != w { + t.Fatalf("Execute data mismatch\n Got: %v\nWant: %v", g, w) + } + + // Get the metadata of the first result set in the rows. + mem, code, _, length, data = Metadata(poolId, connId, rowsId) + + // Iterate over the result sets in the rows object. + // The NextResultSet function returns the metadata of the next result set (if any). + numResultSets := 1 + for { + // Metadata returns actual data, and should therefore return a memory ID that needs to be released. + if mem == int64(0) { + t.Fatalf("Metadata mem mismatch: %v", mem) + } + if length == int32(0) { + t.Fatalf("Metadata length mismatch: %v", length) + } + // Get a []byte from the pointer to the data and the length. + metadataBytes := reflect.SliceAt(reflect.TypeOf(byte(0)), data, int(length)).Bytes() + metadata := &spannerpb.ResultSetMetadata{} + if err := proto.Unmarshal(metadataBytes, metadata); err != nil { + t.Fatal(err) + } + if g, w := len(metadata.RowType.Fields), 1; g != w { + t.Fatalf("Metadata field count mismatch\n Got: %v\nWant: %v", g, w) + } + if g, w := metadata.RowType.Fields[0].Name, "FOO"; g != w { + t.Fatalf("Metadata field name mismatch\n Got: %v\nWant: %v", g, w) + } + if g, w := metadata.RowType.Fields[0].Type.Code, spannerpb.TypeCode_INT64; g != w { + t.Fatalf("Metadata type code mismatch\n Got: %v\nWant: %v", g, w) + } + // Release the memory. + if g, w := Release(mem), int32(0); g != w { + t.Fatalf("Release() result mismatch\n Got: %v\nWant: %v", g, w) + } + + // Iterate over the rows. + numRows := 0 + for { + mem, code, _, length, data = Next(poolId, connId, rowsId /*numRows = */, 1, int32(api.EncodeRowOptionProto)) + // Next returns an empty message if it is the end of the query results. + if length == 0 { + break + } + numRows++ + // Decode the row. + rowBytes := reflect.SliceAt(reflect.TypeOf(byte(0)), data, int(length)).Bytes() + row := &structpb.ListValue{} + if err := proto.Unmarshal(rowBytes, row); err != nil { + t.Fatal(err) + } + // Release the memory that was held for the row. We can do that as soon as it has + // been copied into a data structure that is maintained by the 'application'. + // The 'application' in this case is the test. + if g, w := Release(mem), int32(0); g != w { + t.Fatalf("Release() result mismatch\n Got: %v\nWant: %v", g, w) + } + // Verify the row data. + if g, w := len(row.GetValues()), 1; g != w { + t.Fatalf("num row values mismatch\n Got: %v\nWant: %v", g, w) + } + if g, w := row.GetValues()[0].GetStringValue(), fmt.Sprintf("%d", numRows); g != w { + t.Fatalf("row values mismatch\n Got: %v\nWant: %v", g, w) + } + } + // The result should contain two rows. + if g, w := numRows, 2; g != w { + t.Fatalf("num rows mismatch\n Got: %v\nWant: %v", g, w) + } + + // Get the ResultSetStats. For queries, this is nil. + mem, code, _, length, data = ResultSetStats(poolId, connId, rowsId) + if g, w := code, int32(0); g != w { + t.Fatalf("ResultSetStats result code mismatch\n Got: %v\nWant: %v", g, w) + } + if g, w := length, int32(0); g != w { + t.Fatalf("ResultSetStats length mismatch\n Got: %v\nWant: %v", g, w) + } + if res := Release(mem); res != 0 { + t.Fatalf("Release() result mismatch\n Got: %v\nWant: %v", res, 0) + } + + mem, code, _, length, data = NextResultSet(poolId, connId, rowsId) + if length == 0 { + break + } + numResultSets++ + } + if g, w := numResultSets, 2; g != w { + t.Fatalf("result set count mismatch\n Got: %v\nWant: %v", g, w) + } + + _, code, _, _, _ = CloseRows(poolId, connId, rowsId) + if g, w := code, int32(0); g != w { + t.Fatalf("CloseRows result mismatch\n Got: %v\nWant: %v", g, w) + } + _, code, _, _, _ = CloseConnection(poolId, connId) + if g, w := code, int32(0); g != w { + t.Fatalf("CloseConnection result mismatch\n Got: %v\nWant: %v", g, w) + } + _, code, _, _, _ = ClosePool(poolId) + if g, w := code, int32(0); g != w { + t.Fatalf("ClosePool result mismatch\n Got: %v\nWant: %v", g, w) + } +} + +func TestExecuteBatch(t *testing.T) { + t.Parallel() + + server, teardown := setupMockServer(t) + defer teardown() + dsn := fmt.Sprintf("%s/projects/p/instances/i/databases/d?useplaintext=true", server.Address) + + _, code, poolId, _, _ := CreatePool("test", dsn) + if g, w := code, int32(0); g != w { + t.Fatalf("CreatePool result mismatch\n Got: %v\nWant: %v", g, w) + } + _, code, connId, _, _ := CreateConnection(poolId) + if g, w := code, int32(0); g != w { + t.Fatalf("CreateConnection result mismatch\n Got: %v\nWant: %v", g, w) + } + + request := &spannerpb.ExecuteBatchDmlRequest{ + Statements: []*spannerpb.ExecuteBatchDmlRequest_Statement{ + {Sql: testutil.UpdateBarSetFoo}, + {Sql: testutil.UpdateBarSetFoo}, + }, + } + requestBytes, err := proto.Marshal(request) + if err != nil { + t.Fatal(err) + } + // ExecuteBatch returns a ExecuteBatchDml response. + mem, code, batchId, length, data := ExecuteBatch(poolId, connId, requestBytes) + verifyDataMessage(t, "ExecuteBatch", mem, code, batchId, length, data) + response := &spannerpb.ExecuteBatchDmlResponse{} + responseBytes := reflect.SliceAt(reflect.TypeOf(byte(0)), data, int(length)).Bytes() + if err := proto.Unmarshal(responseBytes, response); err != nil { + t.Fatal(err) + } + if g, w := len(response.ResultSets), 2; g != w { + t.Fatalf("num results mismatch\n Got: %v\nWant: %v", g, w) + } + for i, result := range response.ResultSets { + if g, w := result.Stats.GetRowCountExact(), int64(testutil.UpdateBarSetFooRowCount); g != w { + t.Fatalf("%d: update count mismatch\n Got: %v\nWant: %v", i, g, w) + } + } + // Release the memory held by the response. + if g, w := Release(mem), int32(0); g != w { + t.Fatalf("Release() result mismatch\n Got: %v\nWant: %v", g, w) + } + + _, code, _, _, _ = CloseConnection(poolId, connId) + if g, w := code, int32(0); g != w { + t.Fatalf("CloseConnection result mismatch\n Got: %v\nWant: %v", g, w) + } + _, code, _, _, _ = ClosePool(poolId) + if g, w := code, int32(0); g != w { + t.Fatalf("ClosePool result mismatch\n Got: %v\nWant: %v", g, w) + } +} + +func TestBeginAndCommitTransaction(t *testing.T) { + t.Parallel() + + server, teardown := setupMockServer(t) + defer teardown() + dsn := fmt.Sprintf("%s/projects/p/instances/i/databases/d?useplaintext=true", server.Address) + + _, code, poolId, _, _ := CreatePool("test", dsn) + if g, w := code, int32(0); g != w { + t.Fatalf("CreatePool result mismatch\n Got: %v\nWant: %v", g, w) + } + _, code, connId, _, _ := CreateConnection(poolId) + if g, w := code, int32(0); g != w { + t.Fatalf("CreateConnection result mismatch\n Got: %v\nWant: %v", g, w) + } + + txOpts := &spannerpb.TransactionOptions{} + txOptsBytes, err := proto.Marshal(txOpts) + if err != nil { + t.Fatal(err) + } + mem, code, id, length, res := BeginTransaction(poolId, connId, txOptsBytes) + // BeginTransaction should return an empty message. + // That is, there should be no error code, no ObjectID, and no data. + verifyEmptyMessage(t, "BeginTransaction", mem, code, id, length, res) + + // Execute a statement in the transaction. + request := &spannerpb.ExecuteSqlRequest{Sql: testutil.UpdateBarSetFoo} + requestBytes, err := proto.Marshal(request) + if err != nil { + t.Fatal(err) + } + _, code, rowsId, _, _ := Execute(poolId, connId, requestBytes) + if g, w := code, int32(0); g != w { + t.Fatalf("Execute result mismatch\n Got: %v\nWant: %v", g, w) + } + _, code, _, _, _ = CloseRows(poolId, connId, rowsId) + if g, w := code, int32(0); g != w { + t.Fatalf("CloseRows result mismatch\n Got: %v\nWant: %v", g, w) + } + + // Commit returns the CommitResponse (if any). + mem, code, id, length, res = Commit(poolId, connId) + verifyDataMessage(t, "Commit", mem, code, id, length, res) + + _, code, _, _, _ = CloseConnection(poolId, connId) + if g, w := code, int32(0); g != w { + t.Fatalf("CloseConnection result mismatch\n Got: %v\nWant: %v", g, w) + } + _, code, _, _, _ = ClosePool(poolId) + if g, w := code, int32(0); g != w { + t.Fatalf("ClosePool result mismatch\n Got: %v\nWant: %v", g, w) + } +} + +func TestBeginAndRollbackTransaction(t *testing.T) { + t.Parallel() + + server, teardown := setupMockServer(t) + defer teardown() + dsn := fmt.Sprintf("%s/projects/p/instances/i/databases/d?useplaintext=true", server.Address) + + _, code, poolId, _, _ := CreatePool("test", dsn) + if g, w := code, int32(0); g != w { + t.Fatalf("CreatePool result mismatch\n Got: %v\nWant: %v", g, w) + } + _, code, connId, _, _ := CreateConnection(poolId) + if g, w := code, int32(0); g != w { + t.Fatalf("CreateConnection result mismatch\n Got: %v\nWant: %v", g, w) + } + + txOpts := &spannerpb.TransactionOptions{} + txOptsBytes, err := proto.Marshal(txOpts) + if err != nil { + t.Fatal(err) + } + mem, code, id, length, res := BeginTransaction(poolId, connId, txOptsBytes) + // BeginTransaction should return an empty message. + // That is, there should be no error code, no ObjectID, and no data. + verifyEmptyMessage(t, "BeginTransaction", mem, code, id, length, res) + + // Execute a statement in the transaction. + request := &spannerpb.ExecuteSqlRequest{Sql: testutil.UpdateBarSetFoo} + requestBytes, err := proto.Marshal(request) + if err != nil { + t.Fatal(err) + } + _, code, rowsId, _, _ := Execute(poolId, connId, requestBytes) + if g, w := code, int32(0); g != w { + t.Fatalf("Execute result mismatch\n Got: %v\nWant: %v", g, w) + } + _, code, _, _, _ = CloseRows(poolId, connId, rowsId) + if g, w := code, int32(0); g != w { + t.Fatalf("CloseRows result mismatch\n Got: %v\nWant: %v", g, w) + } + + // Rollback returns nothing. + mem, code, id, length, res = Rollback(poolId, connId) + verifyEmptyMessage(t, "Rollback", mem, code, id, length, res) + + _, code, _, _, _ = CloseConnection(poolId, connId) + if g, w := code, int32(0); g != w { + t.Fatalf("CloseConnection result mismatch\n Got: %v\nWant: %v", g, w) + } + _, code, _, _, _ = ClosePool(poolId) + if g, w := code, int32(0); g != w { + t.Fatalf("ClosePool result mismatch\n Got: %v\nWant: %v", g, w) + } +} + +func TestWriteMutations(t *testing.T) { + t.Parallel() + + server, teardown := setupMockServer(t) + defer teardown() + dsn := fmt.Sprintf("%s/projects/p/instances/i/databases/d?useplaintext=true", server.Address) + + _, code, poolId, _, _ := CreatePool("test", dsn) + if g, w := code, int32(0); g != w { + t.Fatalf("CreatePool result mismatch\n Got: %v\nWant: %v", g, w) + } + _, code, connId, _, _ := CreateConnection(poolId) + if g, w := code, int32(0); g != w { + t.Fatalf("CreateConnection result mismatch\n Got: %v\nWant: %v", g, w) + } + + mutations := &spannerpb.BatchWriteRequest_MutationGroup{Mutations: []*spannerpb.Mutation{ + {Operation: &spannerpb.Mutation_Insert{Insert: &spannerpb.Mutation_Write{ + Table: "my_table", + Columns: []string{"id", "value"}, + Values: []*structpb.ListValue{ + {Values: []*structpb.Value{structpb.NewStringValue("1"), structpb.NewStringValue("One")}}, + {Values: []*structpb.Value{structpb.NewStringValue("2"), structpb.NewStringValue("Two")}}, + {Values: []*structpb.Value{structpb.NewStringValue("3"), structpb.NewStringValue("Three")}}, + }, + }}}, + }} + mutationBytes, err := proto.Marshal(mutations) + if err != nil { + t.Fatal(err) + } + // WriteMutations returns a CommitResponse or nil, depending on whether the connection has an active transaction. + mem, code, id, length, data := WriteMutations(poolId, connId, mutationBytes) + verifyDataMessage(t, "WriteMutations", mem, code, id, length, data) + + response := &spannerpb.CommitResponse{} + responseBytes := reflect.SliceAt(reflect.TypeOf(byte(0)), data, int(length)).Bytes() + if err := proto.Unmarshal(responseBytes, response); err != nil { + t.Fatal(err) + } + if response.CommitTimestamp == nil { + t.Fatal("CommitTimestamp is nil") + } + // Release the memory held by the response. + if g, w := Release(mem), int32(0); g != w { + t.Fatalf("Release() result mismatch\n Got: %v\nWant: %v", g, w) + } + + // Start a transaction on the connection and write the mutations to that transaction. + txOpts := &spannerpb.TransactionOptions{} + txOptsBytes, err := proto.Marshal(txOpts) + _, code, _, _, _ = BeginTransaction(poolId, connId, txOptsBytes) + if g, w := code, int32(0); g != w { + t.Fatalf("BeginTransaction result mismatch\n Got: %v\nWant: %v", g, w) + } + mem, code, id, length, data = WriteMutations(poolId, connId, mutationBytes) + // The response should now be an empty message, as the mutations were buffered in the current transaction. + verifyEmptyMessage(t, "WriteMutations in tx", mem, code, id, length, data) + + _, code, _, _, _ = CloseConnection(poolId, connId) + if g, w := code, int32(0); g != w { + t.Fatalf("CloseConnection result mismatch\n Got: %v\nWant: %v", g, w) + } + _, code, _, _, _ = ClosePool(poolId) + if g, w := code, int32(0); g != w { + t.Fatalf("ClosePool result mismatch\n Got: %v\nWant: %v", g, w) + } +} + +func verifyEmptyMessage(t *testing.T, name string, mem int64, code int32, id int64, length int32, res unsafe.Pointer) { + if g, w := mem, int64(0); g != w { + t.Fatalf("%s: mem ID mismatch\n Got: %v\nWant: %v", name, g, w) + } + if g, w := code, int32(0); g != w { + t.Fatalf("%s: result mismatch\n Got: %v\nWant: %v", name, g, w) + } + if g, w := id, int64(0); g != w { + t.Fatalf("%s: ID mismatch\n Got: %v\nWant: %v", name, g, w) + } + if g, w := length, int32(0); g != w { + t.Fatalf("%s: length mismatch\n Got: %v\nWant: %v", name, g, w) + } + if g, w := res, unsafe.Pointer(nil); g != w { + t.Fatalf("%s: ptr mismatch\n Got: %v\nWant: %v", name, g, w) + } +} + +// verifyDataMessage verifies that the result contains a data message. +func verifyDataMessage(t *testing.T, name string, mem int64, code int32, id int64, length int32, res unsafe.Pointer) { + if g, w := code, int32(0); g != w { + t.Fatalf("%s: result mismatch\n Got: %v\nWant: %v", name, g, w) + } + if mem == int64(0) { + t.Fatalf("%s: No memory identifier returned", name) + } + if g, w := id, int64(0); g != w { + t.Fatalf("%s: ID mismatch\n Got: %v\nWant: %v", name, g, w) + } + if length == int32(0) { + t.Fatalf("%s: zero length returned", name) + } + if res == unsafe.Pointer(nil) { + t.Fatalf("%s: nil pointer returned", name) + } +} + +func countOpenMemoryPointers() (c int) { + pinners.Range(func(key, value any) bool { + c++ + return true + }) + return +} + +func setupMockServer(t *testing.T) (server *testutil.MockedSpannerInMemTestServer, teardown func()) { + return setupMockServerWithDialect(t, databasepb.DatabaseDialect_GOOGLE_STANDARD_SQL) +} + +func setupMockServerWithDialect(t *testing.T, dialect databasepb.DatabaseDialect) (server *testutil.MockedSpannerInMemTestServer, teardown func()) { + server, _, serverTeardown := testutil.NewMockedSpannerInMemTestServer(t) + server.SetupSelectDialectResult(dialect) + return server, serverTeardown +} diff --git a/handwritten/spanner-driver/spannerlib-node/src/cpp/addon.cc b/handwritten/spanner-driver/spannerlib-node/src/cpp/addon.cc new file mode 100644 index 000000000000..532b2ae8787e --- /dev/null +++ b/handwritten/spanner-driver/spannerlib-node/src/cpp/addon.cc @@ -0,0 +1,672 @@ +// 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. + +#include +#include "libspanner.h" + +// Documentation for Go function return fields (r0 - r4): +// r0: Pinner ID (used for memory management to keep Go objects pinned) +// r1: Error Code (0 for success, non-zero for error) +// r2: Object ID (Handle to the created object, e.g., Pool or Connection) +// r3: Message Length (Length of the protobuf message or error string in r4) +// r4: Message Data (Pointer to protobuf bytes or JSON error message) + +template +Napi::Object CreateResultObject(Napi::Env env, const T& result) { + Napi::Object obj = Napi::Object::New(env); + obj.Set("r0", Napi::Number::New(env, 0)); + obj.Set("r1", Napi::Number::New(env, result.r1)); + obj.Set("r2", Napi::Number::New(env, result.r2)); + obj.Set("r3", Napi::Number::New(env, result.r3)); + if (result.r4 != nullptr && result.r3 > 0) { + obj.Set("r4", Napi::Buffer::Copy(env, (uint8_t*)result.r4, result.r3)); + } else { + obj.Set("r4", env.Null()); + } + if (result.r0 > 0) { + ::Release(result.r0); + } + return obj; +} + +// +// Worker 1: CreatePool asynchronously +// +class CreatePoolWorker : public Napi::AsyncWorker { +public: + CreatePoolWorker(Napi::Function& callback, std::string userAgent, std::string connStr) + : AsyncWorker(callback), userAgent_(userAgent), connStr_(connStr), result_({0, 0, 0, 0, nullptr}) {} + + void Execute() override { + GoString goUserAgent = {userAgent_.c_str(), (ptrdiff_t)userAgent_.length()}; + GoString goConnStr = {connStr_.c_str(), (ptrdiff_t)connStr_.length()}; + result_ = CreatePool(goUserAgent, goConnStr); + } + + void OnOK() override { + Napi::Env env = Env(); + Napi::Object obj = CreateResultObject(env, result_); + Callback().Call({env.Null(), obj}); + } + +private: + std::string userAgent_; + std::string connStr_; + CreatePool_return result_; +}; + +Napi::Value CreatePoolWrapper(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + if (info.Length() < 3 || !info[0].IsString() || !info[1].IsString() || !info[2].IsFunction()) { + Napi::Error::New(env, "CreatePoolWrapper requires (String, String, Function)").ThrowAsJavaScriptException(); + return env.Null(); + } + std::string ua = info[0].As(); + std::string cs = info[1].As(); + Napi::Function cb = info[2].As(); + CreatePoolWorker* worker = new CreatePoolWorker(cb, ua, cs); + worker->Queue(); + return env.Undefined(); +} + +// +// Worker 2: ClosePool asynchronously +// +class ClosePoolWorker : public Napi::AsyncWorker { +public: + ClosePoolWorker(Napi::Function& callback, int64_t poolId) + : AsyncWorker(callback), poolId_(poolId), result_({0, 0, 0, 0, nullptr}) {} + + void Execute() override { + result_ = ClosePool(poolId_); + } + + void OnOK() override { + Napi::Env env = Env(); + Napi::Object obj = CreateResultObject(env, result_); + Callback().Call({env.Null(), obj}); + } +private: + int64_t poolId_; + ClosePool_return result_; +}; + +Napi::Value ClosePoolWrapper(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + if (info.Length() < 2 || !info[0].IsNumber() || !info[1].IsFunction()) { + Napi::Error::New(env, "ClosePoolWrapper requires (Number, Function)").ThrowAsJavaScriptException(); + return env.Null(); + } + int64_t pid = info[0].As().Int64Value(); + Napi::Function cb = info[1].As(); + ClosePoolWorker* worker = new ClosePoolWorker(cb, pid); + worker->Queue(); + return env.Undefined(); +} + +// +// Worker 3: CreateConnection asynchronously +// +class CreateConnectionWorker : public Napi::AsyncWorker { +public: + CreateConnectionWorker(Napi::Function& callback, int64_t poolId) + : AsyncWorker(callback), poolId_(poolId), result_({0, 0, 0, 0, nullptr}) {} + + void Execute() override { + result_ = CreateConnection(poolId_); + } + + void OnOK() override { + Napi::Env env = Env(); + Napi::Object obj = CreateResultObject(env, result_); + Callback().Call({env.Null(), obj}); + } +private: + int64_t poolId_; + CreateConnection_return result_; +}; + +Napi::Value CreateConnectionWrapper(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + if (info.Length() < 2 || !info[0].IsNumber() || !info[1].IsFunction()) { + Napi::Error::New(env, "CreateConnectionWrapper requires (Number, Function)").ThrowAsJavaScriptException(); + return env.Null(); + } + int64_t pid = info[0].As().Int64Value(); + Napi::Function cb = info[1].As(); + CreateConnectionWorker* worker = new CreateConnectionWorker(cb, pid); + worker->Queue(); + return env.Undefined(); +} + +// +// Worker 4: CloseConnection asynchronously +// +class CloseConnectionWorker : public Napi::AsyncWorker { +public: + CloseConnectionWorker(Napi::Function& callback, int64_t poolId, int64_t connId) + : AsyncWorker(callback), poolId_(poolId), connId_(connId), result_({0, 0, 0, 0, nullptr}) {} + + void Execute() override { + result_ = CloseConnection(poolId_, connId_); + } + + void OnOK() override { + Napi::Env env = Env(); + Napi::Object obj = CreateResultObject(env, result_); + Callback().Call({env.Null(), obj}); + } +private: + int64_t poolId_, connId_; + CloseConnection_return result_; +}; + +Napi::Value CloseConnectionWrapper(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + if (info.Length() < 3 || !info[0].IsNumber() || !info[1].IsNumber() || !info[2].IsFunction()) { + Napi::Error::New(env, "CloseConnectionWrapper requires (Number, Number, Function)").ThrowAsJavaScriptException(); + return env.Null(); + } + int64_t pid = info[0].As().Int64Value(); + int64_t cid = info[1].As().Int64Value(); + Napi::Function cb = info[2].As(); + CloseConnectionWorker* worker = new CloseConnectionWorker(cb, pid, cid); + worker->Queue(); + return env.Undefined(); +} + +// +// Worker 5: Execute asynchronously +// +class ExecuteWorker : public Napi::AsyncWorker { +public: + ExecuteWorker(Napi::Function& callback, int64_t poolId, int64_t connId, std::string payload) + : AsyncWorker(callback), poolId_(poolId), connId_(connId), payload_(payload), result_({0, 0, 0, 0, nullptr}) {} + + void Execute() override { + GoSlice goPayload = {(void*)payload_.data(), (ptrdiff_t)payload_.length(), (ptrdiff_t)payload_.length()}; + result_ = ::Execute(poolId_, connId_, goPayload); + } + + void OnOK() override { + Napi::Env env = Env(); + Napi::Object obj = CreateResultObject(env, result_); + Callback().Call({env.Null(), obj}); + } +private: + int64_t poolId_, connId_; + std::string payload_; + Execute_return result_; +}; + +Napi::Value ExecuteWrapper(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + if (info.Length() < 4) { + Napi::Error::New(env, "ExecuteWrapper requires 4 arguments").ThrowAsJavaScriptException(); + return env.Null(); + } + if (!info[0].IsNumber() || !info[1].IsNumber() || !info[2].IsBuffer() || !info[3].IsFunction()) { + Napi::Error::New(env, "Invalid argument types in ExecuteWrapper").ThrowAsJavaScriptException(); + return env.Null(); + } + int64_t pid = info[0].As().Int64Value(); + int64_t cid = info[1].As().Int64Value(); + + Napi::Buffer buffer = info[2].As>(); + std::string payload(buffer.Length() > 0 ? reinterpret_cast(buffer.Data()) : "", buffer.Length()); + + Napi::Function cb = info[3].As(); + ExecuteWorker* worker = new ExecuteWorker(cb, pid, cid, payload); + worker->Queue(); + return env.Undefined(); +} + +// +// Worker 6: Next asynchronously +// +class NextWorker : public Napi::AsyncWorker { +public: + NextWorker(Napi::Function& callback, int64_t poolId, int64_t connId, int64_t rowsId, int32_t numRows, int32_t encodeOtp) + : AsyncWorker(callback), poolId_(poolId), connId_(connId), rowsId_(rowsId), numRows_(numRows), encodeOtp_(encodeOtp), result_({0, 0, 0, 0, nullptr}) {} + + void Execute() override { + result_ = ::Next(poolId_, connId_, rowsId_, numRows_, encodeOtp_); + } + + void OnOK() override { + Napi::Env env = Env(); + Napi::Object obj = CreateResultObject(env, result_); + Callback().Call({env.Null(), obj}); + } +private: + int64_t poolId_, connId_, rowsId_; + int32_t numRows_, encodeOtp_; + Next_return result_; +}; + +Napi::Value NextWrapper(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + if (info.Length() < 6 || !info[0].IsNumber() || !info[1].IsNumber() || !info[2].IsNumber() || + !info[3].IsNumber() || !info[4].IsNumber() || !info[5].IsFunction()) { + Napi::Error::New(env, "NextWrapper requires (Number, Number, Number, Number, Number, Function)").ThrowAsJavaScriptException(); + return env.Null(); + } + int64_t pid = info[0].As().Int64Value(); + int64_t cid = info[1].As().Int64Value(); + int64_t rid = info[2].As().Int64Value(); + int32_t num = info[3].As().Int32Value(); + int32_t encode = info[4].As().Int32Value(); + Napi::Function cb = info[5].As(); + + NextWorker* worker = new NextWorker(cb, pid, cid, rid, num, encode); + worker->Queue(); + return env.Undefined(); +} + +// +// Worker 7: Metadata asynchronously +// +class MetadataWorker : public Napi::AsyncWorker { +public: + MetadataWorker(Napi::Function& callback, int64_t poolId, int64_t connId, int64_t rowsId) + : AsyncWorker(callback), poolId_(poolId), connId_(connId), rowsId_(rowsId), result_({0, 0, 0, 0, nullptr}) {} + + void Execute() override { + result_ = ::Metadata(poolId_, connId_, rowsId_); + } + + void OnOK() override { + Napi::Env env = Env(); + Napi::Object obj = CreateResultObject(env, result_); + Callback().Call({env.Null(), obj}); + } +private: + int64_t poolId_, connId_, rowsId_; + Metadata_return result_; +}; + +// +// Worker 8: CloseRows asynchronously +// +class CloseRowsWorker : public Napi::AsyncWorker { +public: + CloseRowsWorker(Napi::Function& callback, int64_t poolId, int64_t connId, int64_t rowsId) + : AsyncWorker(callback), poolId_(poolId), connId_(connId), rowsId_(rowsId), result_({0, 0, 0, 0, nullptr}) {} + + void Execute() override { + result_ = ::CloseRows(poolId_, connId_, rowsId_); + } + + void OnOK() override { + Napi::Env env = Env(); + Napi::Object obj = CreateResultObject(env, result_); + Callback().Call({env.Null(), obj}); + } +private: + int64_t poolId_, connId_, rowsId_; + CloseRows_return result_; +}; + +Napi::Value MetadataWrapper(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + if (info.Length() < 4 || !info[0].IsNumber() || !info[1].IsNumber() || !info[2].IsNumber() || !info[3].IsFunction()) { + Napi::Error::New(env, "MetadataWrapper requires (Number, Number, Number, Function)").ThrowAsJavaScriptException(); + return env.Null(); + } + int64_t pid = info[0].As().Int64Value(); + int64_t cid = info[1].As().Int64Value(); + int64_t rid = info[2].As().Int64Value(); + Napi::Function cb = info[3].As(); + + MetadataWorker* worker = new MetadataWorker(cb, pid, cid, rid); + worker->Queue(); + return env.Undefined(); +} + +// +// Worker 7b: ResultSetStats asynchronously +// +class ResultSetStatsWorker : public Napi::AsyncWorker { +public: + ResultSetStatsWorker(Napi::Function& callback, int64_t poolId, int64_t connId, int64_t rowsId) + : AsyncWorker(callback), poolId_(poolId), connId_(connId), rowsId_(rowsId), result_({0, 0, 0, 0, nullptr}) {} + + void Execute() override { + result_ = ::ResultSetStats(poolId_, connId_, rowsId_); + } + + void OnOK() override { + Napi::Env env = Env(); + Napi::Object obj = CreateResultObject(env, result_); + Callback().Call({env.Null(), obj}); + } +private: + int64_t poolId_, connId_, rowsId_; + ResultSetStats_return result_; +}; + +Napi::Value ResultSetStatsWrapper(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + if (info.Length() < 4 || !info[0].IsNumber() || !info[1].IsNumber() || !info[2].IsNumber() || !info[3].IsFunction()) { + Napi::Error::New(env, "ResultSetStatsWrapper requires (Number, Number, Number, Function)").ThrowAsJavaScriptException(); + return env.Null(); + } + int64_t pid = info[0].As().Int64Value(); + int64_t cid = info[1].As().Int64Value(); + int64_t rid = info[2].As().Int64Value(); + Napi::Function cb = info[3].As(); + + ResultSetStatsWorker* worker = new ResultSetStatsWorker(cb, pid, cid, rid); + worker->Queue(); + return env.Undefined(); +} + +// +// Worker 7c: NextResultSet asynchronously +// +class NextResultSetWorker : public Napi::AsyncWorker { +public: + NextResultSetWorker(Napi::Function& callback, int64_t poolId, int64_t connId, int64_t rowsId) + : AsyncWorker(callback), poolId_(poolId), connId_(connId), rowsId_(rowsId), result_({0, 0, 0, 0, nullptr}) {} + + void Execute() override { + result_ = ::NextResultSet(poolId_, connId_, rowsId_); + } + + void OnOK() override { + Napi::Env env = Env(); + Napi::Object obj = CreateResultObject(env, result_); + Callback().Call({env.Null(), obj}); + } +private: + int64_t poolId_, connId_, rowsId_; + NextResultSet_return result_; +}; + +Napi::Value NextResultSetWrapper(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + if (info.Length() < 4 || !info[0].IsNumber() || !info[1].IsNumber() || !info[2].IsNumber() || !info[3].IsFunction()) { + Napi::Error::New(env, "NextResultSetWrapper requires (Number, Number, Number, Function)").ThrowAsJavaScriptException(); + return env.Null(); + } + int64_t pid = info[0].As().Int64Value(); + int64_t cid = info[1].As().Int64Value(); + int64_t rid = info[2].As().Int64Value(); + Napi::Function cb = info[3].As(); + + NextResultSetWorker* worker = new NextResultSetWorker(cb, pid, cid, rid); + worker->Queue(); + return env.Undefined(); +} + + +// Memory Release (Synchronous as it is just freeing RAM via GC) +Napi::Value NativeRelease(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + if (info.Length() < 1 || !info[0].IsNumber()) return env.Null(); + int64_t pid = info[0].As().Int64Value(); + Release(pid); + return env.Undefined(); +} + +Napi::Value CloseRowsWrapper(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + if (info.Length() < 4 || !info[0].IsNumber() || !info[1].IsNumber() || !info[2].IsNumber() || !info[3].IsFunction()) { + Napi::Error::New(env, "CloseRowsWrapper requires (Number, Number, Number, Function)").ThrowAsJavaScriptException(); + return env.Null(); + } + int64_t pid = info[0].As().Int64Value(); + int64_t cid = info[1].As().Int64Value(); + int64_t rid = info[2].As().Int64Value(); + Napi::Function cb = info[3].As(); + + CloseRowsWorker* worker = new CloseRowsWorker(cb, pid, cid, rid); + worker->Queue(); + return env.Undefined(); +} + +// +// Worker 9: BeginTransaction asynchronously +// +class BeginTransactionWorker : public Napi::AsyncWorker { +public: + BeginTransactionWorker(Napi::Function& callback, int64_t poolId, int64_t connId, std::string payload) + : AsyncWorker(callback), poolId_(poolId), connId_(connId), payload_(payload), result_({0, 0, 0, 0, nullptr}) {} + + void Execute() override { + GoSlice goPayload = {(void*)payload_.data(), (ptrdiff_t)payload_.length(), (ptrdiff_t)payload_.length()}; + result_ = ::BeginTransaction(poolId_, connId_, goPayload); + } + + void OnOK() override { + Napi::Env env = Env(); + Napi::Object obj = CreateResultObject(env, result_); + Callback().Call({env.Null(), obj}); + } +private: + int64_t poolId_, connId_; + std::string payload_; + BeginTransaction_return result_; +}; + +Napi::Value BeginTransactionWrapper(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + if (info.Length() < 4) { + Napi::Error::New(env, "BeginTransactionWrapper requires 4 arguments").ThrowAsJavaScriptException(); + return env.Null(); + } + if (!info[0].IsNumber() || !info[1].IsNumber() || !info[2].IsBuffer() || !info[3].IsFunction()) { + Napi::Error::New(env, "Invalid argument types in BeginTransactionWrapper").ThrowAsJavaScriptException(); + return env.Null(); + } + int64_t pid = info[0].As().Int64Value(); + int64_t cid = info[1].As().Int64Value(); + + Napi::Buffer buffer = info[2].As>(); + std::string payload(buffer.Length() > 0 ? reinterpret_cast(buffer.Data()) : "", buffer.Length()); + + Napi::Function cb = info[3].As(); + BeginTransactionWorker* worker = new BeginTransactionWorker(cb, pid, cid, payload); + worker->Queue(); + return env.Undefined(); +} + +// +// Worker 10: Commit asynchronously +// +class CommitWorker : public Napi::AsyncWorker { +public: + CommitWorker(Napi::Function& callback, int64_t poolId, int64_t connId) + : AsyncWorker(callback), poolId_(poolId), connId_(connId), result_({0, 0, 0, 0, nullptr}) {} + + void Execute() override { + result_ = ::Commit(poolId_, connId_); + } + + void OnOK() override { + Napi::Env env = Env(); + Napi::Object obj = CreateResultObject(env, result_); + Callback().Call({env.Null(), obj}); + } +private: + int64_t poolId_, connId_; + Commit_return result_; +}; + +Napi::Value CommitWrapper(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + if (info.Length() < 3 || !info[0].IsNumber() || !info[1].IsNumber() || !info[2].IsFunction()) { + Napi::Error::New(env, "CommitWrapper requires (Number, Number, Function)").ThrowAsJavaScriptException(); + return env.Null(); + } + int64_t pid = info[0].As().Int64Value(); + int64_t cid = info[1].As().Int64Value(); + Napi::Function cb = info[2].As(); + CommitWorker* worker = new CommitWorker(cb, pid, cid); + worker->Queue(); + return env.Undefined(); +} + +// +// Worker 11: Rollback asynchronously +// +class RollbackWorker : public Napi::AsyncWorker { +public: + RollbackWorker(Napi::Function& callback, int64_t poolId, int64_t connId) + : AsyncWorker(callback), poolId_(poolId), connId_(connId), result_({0, 0, 0, 0, nullptr}) {} + + void Execute() override { + result_ = ::Rollback(poolId_, connId_); + } + + void OnOK() override { + Napi::Env env = Env(); + Napi::Object obj = CreateResultObject(env, result_); + Callback().Call({env.Null(), obj}); + } +private: + int64_t poolId_, connId_; + Rollback_return result_; +}; + +Napi::Value RollbackWrapper(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + if (info.Length() < 3 || !info[0].IsNumber() || !info[1].IsNumber() || !info[2].IsFunction()) { + Napi::Error::New(env, "RollbackWrapper requires (Number, Number, Function)").ThrowAsJavaScriptException(); + return env.Null(); + } + int64_t pid = info[0].As().Int64Value(); + int64_t cid = info[1].As().Int64Value(); + Napi::Function cb = info[2].As(); + RollbackWorker* worker = new RollbackWorker(cb, pid, cid); + worker->Queue(); + return env.Undefined(); +} + +// +// Worker 12: WriteMutations asynchronously +// +class WriteMutationsWorker : public Napi::AsyncWorker { +public: + WriteMutationsWorker(Napi::Function& callback, int64_t poolId, int64_t connId, std::string payload) + : AsyncWorker(callback), poolId_(poolId), connId_(connId), payload_(payload), result_({0, 0, 0, 0, nullptr}) {} + + void Execute() override { + GoSlice goPayload = {(void*)payload_.data(), (ptrdiff_t)payload_.length(), (ptrdiff_t)payload_.length()}; + result_ = ::WriteMutations(poolId_, connId_, goPayload); + } + + void OnOK() override { + Napi::Env env = Env(); + Napi::Object obj = CreateResultObject(env, result_); + Callback().Call({env.Null(), obj}); + } +private: + int64_t poolId_, connId_; + std::string payload_; + WriteMutations_return result_; +}; + +Napi::Value WriteMutationsWrapper(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + if (info.Length() < 4) { + Napi::Error::New(env, "WriteMutationsWrapper requires 4 arguments").ThrowAsJavaScriptException(); + return env.Null(); + } + if (!info[0].IsNumber() || !info[1].IsNumber() || !info[2].IsBuffer() || !info[3].IsFunction()) { + Napi::Error::New(env, "Invalid argument types in WriteMutationsWrapper").ThrowAsJavaScriptException(); + return env.Null(); + } + int64_t pid = info[0].As().Int64Value(); + int64_t cid = info[1].As().Int64Value(); + + Napi::Buffer buffer = info[2].As>(); + std::string payload(buffer.Length() > 0 ? reinterpret_cast(buffer.Data()) : "", buffer.Length()); + + Napi::Function cb = info[3].As(); + WriteMutationsWorker* worker = new WriteMutationsWorker(cb, pid, cid, payload); + worker->Queue(); + return env.Undefined(); +} + +// +// Worker 13: ExecuteBatch asynchronously +// +class ExecuteBatchWorker : public Napi::AsyncWorker { +public: + ExecuteBatchWorker(Napi::Function& callback, int64_t poolId, int64_t connId, std::string payload) + : AsyncWorker(callback), poolId_(poolId), connId_(connId), payload_(payload), result_({0, 0, 0, 0, nullptr}) {} + + void Execute() override { + GoSlice goPayload = {(void*)payload_.data(), (ptrdiff_t)payload_.length(), (ptrdiff_t)payload_.length()}; + result_ = ::ExecuteBatch(poolId_, connId_, goPayload); + } + + void OnOK() override { + Napi::Env env = Env(); + Napi::Object obj = CreateResultObject(env, result_); + Callback().Call({env.Null(), obj}); + } +private: + int64_t poolId_, connId_; + std::string payload_; + ExecuteBatch_return result_; +}; + +Napi::Value ExecuteBatchWrapper(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + if (info.Length() < 4) { + Napi::Error::New(env, "ExecuteBatchWrapper requires 4 arguments").ThrowAsJavaScriptException(); + return env.Null(); + } + if (!info[0].IsNumber() || !info[1].IsNumber() || !info[2].IsBuffer() || !info[3].IsFunction()) { + Napi::Error::New(env, "Invalid argument types in ExecuteBatchWrapper").ThrowAsJavaScriptException(); + return env.Null(); + } + int64_t pid = info[0].As().Int64Value(); + int64_t cid = info[1].As().Int64Value(); + + Napi::Buffer buffer = info[2].As>(); + std::string payload(buffer.Length() > 0 ? reinterpret_cast(buffer.Data()) : "", buffer.Length()); + + Napi::Function cb = info[3].As(); + ExecuteBatchWorker* worker = new ExecuteBatchWorker(cb, pid, cid, payload); + worker->Queue(); + return env.Undefined(); +} + +Napi::Object Init(Napi::Env env, Napi::Object exports) { + exports.Set("CreatePool", Napi::Function::New(env, CreatePoolWrapper)); + exports.Set("ClosePool", Napi::Function::New(env, ClosePoolWrapper)); + exports.Set("CreateConnection", Napi::Function::New(env, CreateConnectionWrapper)); + exports.Set("CloseConnection", Napi::Function::New(env, CloseConnectionWrapper)); + exports.Set("Execute", Napi::Function::New(env, ExecuteWrapper)); + exports.Set("Next", Napi::Function::New(env, NextWrapper)); + + exports.Set("CloseRows", Napi::Function::New(env, CloseRowsWrapper)); + exports.Set("Release", Napi::Function::New(env, NativeRelease)); + + exports.Set("Metadata", Napi::Function::New(env, MetadataWrapper)); + exports.Set("ResultSetStats", Napi::Function::New(env, ResultSetStatsWrapper)); + exports.Set("NextResultSet", Napi::Function::New(env, NextResultSetWrapper)); + + exports.Set("BeginTransaction", Napi::Function::New(env, BeginTransactionWrapper)); + exports.Set("Commit", Napi::Function::New(env, CommitWrapper)); + exports.Set("Rollback", Napi::Function::New(env, RollbackWrapper)); + exports.Set("WriteMutations", Napi::Function::New(env, WriteMutationsWrapper)); + exports.Set("ExecuteBatch", Napi::Function::New(env, ExecuteBatchWrapper)); + return exports; +} + +NODE_API_MODULE(spanner_napi, Init) diff --git a/handwritten/spanner-driver/spannerlib-node/src/ffi/utils.ts b/handwritten/spanner-driver/spannerlib-node/src/ffi/utils.ts new file mode 100644 index 000000000000..9fe28a8c398d --- /dev/null +++ b/handwritten/spanner-driver/spannerlib-node/src/ffi/utils.ts @@ -0,0 +1,127 @@ +// 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 bindings from 'bindings'; +const addon = bindings('spanner_napi'); + +/** + * Payload encoding strategy identifier for JSON serialization. + */ +export const ENCODING_JSON = 0; + +/** + * Payload encoding strategy identifier for Protobuf serialization (Default). + */ +export const ENCODING_PROTOBUF = 1; + +/** + * Represents the structured, user-friendly outcome of a successful native Go SDK invocation. + * The raw, cryptic `r0`–`r4` CGO sequential struct is parsed and mapped into this interface. + * + * @property {number} objectId - The active numeric resource ID returned by Go for the newly created or manipulated entity (e.g., the Pool ID, Connection ID, or Rows iterator ID). + * @property {number} pinnerId - The internal memory lock pointer ID. Used by the Go SDK to keep the returned buffer pinned in memory until it's safely copied. + * @property {Buffer | null} protobufBytes - The raw response payload bytes (e.g., query result sets or serialized protobuf structures). + */ +export interface HandledResult { + objectId: number; + pinnerId: number; + protobufBytes: Buffer | null; +} + +/** + * Represents the low-level CGO return struct generated by the underlying Go shared library. + * Because Go natively supports multiple return values, the CGO compiler exports them + * into a sequential C-struct. This interface maps directly to those output parameters: + * + * @property {number} r0 - Pinner ID. An internal pointer reference to a pinned Go memory block (locked for garbage collection safety). If > 0, the native bridge must manually release it after copying raw data. + * @property {number} r1 - Native Error Code. The numeric status code of the native operation (0 signifies success, non-zero signifies a native Go Spanner error). + * @property {number} r2 - Object Handle ID. The generated resource ID representing the Go database object (Pool ID, Connection ID, or Rows ID). + * @property {number} r3 - Buffer Size / Length. The byte length of the returned data payload or native error string in `r4`. + * @property {Buffer | null} r4 - Payload Pointer. The raw memory buffer containing the serialized protobuf data, JSON response, or native error payload. + */ +interface AddonResult { + r0: number; + r1: number; + r2: number; + r3: number; + r4: Buffer | null; +} + +/** + * Asynchronously dispatches a function call to the native C++ N-API addon layer, bridging + * execution to the Go Spanner shared library in a background OS worker thread. + * + * @param {string} funcName - The name of the native function exposed by the C++ addon to invoke (e.g., 'CreatePool', 'Execute'). + * @param {...unknown[]} args - The sequential arguments to pass to the native C++ handler. + * @returns {Promise} A Promise that resolves to the structured `HandledResult`, or rejects with a `SpannerLibError` if the native operation fails. + */ +function invokeAsync( + funcName: string, + ...args: unknown[] +): Promise { + return new Promise((resolve, reject) => { + const callback = (err: unknown, result: AddonResult) => { + if (err) { + return reject(err); + } + if (result.r1 !== 0) { + if (result.r4 && result.r3 > 0) { + const errorJson = result.r4.toString('utf8'); + try { + const parsed = JSON.parse(errorJson); + return reject(new SpannerLibError(parsed.message || errorJson)); + } catch { + return reject(new SpannerLibError(errorJson)); + } + } + return reject( + new SpannerLibError(`Native Spanner Error Code: ${result.r1}`) + ); + } + + resolve({ + objectId: result.r2, + pinnerId: result.r0, + protobufBytes: result.r4, + }); + }; + + addon[funcName](...args, callback); + }); +} + +/** + * The Foreign Function Interface (FFI) dispatcher instance. + * Houses the async dispatcher and synchronous native release hooks for the underlying C++ N-API bridge. + */ +export const ffi = { + /** + * Dispatches an asynchronous native invocation to the Go/C++ layer on a background thread. + */ + invokeAsync, + /** + * Synchronously invokes a native memory unpin operation for a locked Go pointer handle. + */ + Release: addon.Release, +}; + +/** + * Custom error class representing an exception thrown by the native Go Spanner library wrapper. + */ +export class SpannerLibError extends Error { + constructor(message: string) { + super(message); + this.name = 'SpannerLibError'; + } +} diff --git a/handwritten/spanner-driver/spannerlib-node/src/index.ts b/handwritten/spanner-driver/spannerlib-node/src/index.ts new file mode 100644 index 000000000000..87f68f79eba3 --- /dev/null +++ b/handwritten/spanner-driver/spannerlib-node/src/index.ts @@ -0,0 +1,29 @@ +// 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. + +/** + * @fileoverview Main entry point for the Node.js Spanner N-API Wrapper. + * + * This package provides a high-performance, crash-proof, and memory-stable + * Node-API (N-API) bridge to the native Go-based Spanner SDK. It exposes an + * object-oriented API for managing database connection pools, executing queries, + * and iterating through raw result sets utilizing ES Modules (ESM) and CommonJS. + */ + +import { Pool } from './lib/pool.js'; +import { Connection } from './lib/connection.js'; +import { Rows } from './lib/rows.js'; +import { SpannerLibError } from './ffi/utils.js'; + +export { Pool, Connection, Rows, SpannerLibError }; diff --git a/handwritten/spanner-driver/spannerlib-node/src/lib/connection.ts b/handwritten/spanner-driver/spannerlib-node/src/lib/connection.ts new file mode 100644 index 000000000000..cf3d538413a5 --- /dev/null +++ b/handwritten/spanner-driver/spannerlib-node/src/lib/connection.ts @@ -0,0 +1,250 @@ +// 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 { ffi } from '../ffi/utils.js'; +import { spannerLib } from './spannerlib.js'; +import { Pool } from './pool.js'; +import { Rows } from './rows.js'; +// TODO: Avoid tight coupling to internal paths of full client libraries. +// Unlike other languages like Java, Python , Node client does not export its protos. +import pkg from '@google-cloud/spanner/build/protos/protos.js'; +const { google } = pkg; +import type { google as GoogleProto } from '@google-cloud/spanner/build/protos/protos.js'; + +/** + * Manages a connection to the Spanner database. + * + * This class wraps the connection handle from the underlying Go library, + * providing methods to execute SQL statements and manage transactions. + */ +export class Connection { + public pool: Pool | null; + public oid: number | null; + public closed: boolean; + + /** + * Creates a new connection within the specified pool. + * + * @param pool The pool to create the connection in. + * @returns A Promise that resolves to a new Connection instance. + * @throws {SpannerLibError} If creation fails in the Go library. + */ + static async create(pool: Pool): Promise { + if (pool.closed || pool.oid === null) { + throw new Error('Cannot create connection: Pool is closed or invalid'); + } + const c = new Connection(); + c.pool = pool; + + const handled = await ffi.invokeAsync('CreateConnection', pool.oid); + + c.oid = handled.objectId; + spannerLib.register(c, { + type: 'connection', + poolId: pool.oid, + connectionId: c.oid!, + }); + return c; + } + + constructor() { + this.pool = null; + this.oid = null; + this.closed = false; + } + + /** + * Executes a SQL statement on this connection. + * + * @param request A SQL string or ExecuteSqlRequest object. + * @returns A Promise that resolves to a Rows instance containing results. + */ + async execute( + request: string | GoogleProto.spanner.v1.IExecuteSqlRequest + ): Promise { + this.ensureOpenAndValid(); + + const requestObj = typeof request === 'string' ? { sql: request } : request; + const ExecuteSqlRequestProto = google.spanner.v1.ExecuteSqlRequest; + const serializedPb = Buffer.from( + ExecuteSqlRequestProto.encode(requestObj).finish() + ); + + const rowsResult = await ffi.invokeAsync( + 'Execute', + this.pool!.oid, + this.oid, + serializedPb + ); + const rowsId = rowsResult.objectId; + + return new Rows(this, rowsId); + } + + /** + * Begins an explicit Spanner transaction on this connection. + * + * @param options Optional transaction options (e.g., read-only vs read-write). + */ + async beginTransaction( + options?: GoogleProto.spanner.v1.ITransactionOptions + ): Promise { + this.ensureOpenAndValid(); + const TransactionOptionsProto = google.spanner.v1.TransactionOptions; + const serializedPb = options + ? Buffer.from(TransactionOptionsProto.encode(options).finish()) + : Buffer.alloc(0); + + await ffi.invokeAsync( + 'BeginTransaction', + this.pool!.oid, + this.oid, + serializedPb + ); + } + + /** + * Commits the active transaction on this connection. + * + * @returns A Promise that resolves to the CommitResponse (containing commitTimestamp). + */ + async commit(): Promise { + this.ensureOpenAndValid(); + const res = await ffi.invokeAsync('Commit', this.pool!.oid, this.oid); + if (res.protobufBytes && res.protobufBytes.length > 0) { + return google.spanner.v1.CommitResponse.decode(res.protobufBytes); + } + return {}; + } + + /** + * Rolls back the active transaction on this connection. + */ + async rollback(): Promise { + this.ensureOpenAndValid(); + await ffi.invokeAsync('Rollback', this.pool!.oid, this.oid); + } + + /** + * Writes an array of mutations or a MutationGroup to Spanner. + * + * @param mutations An array of Mutations or a MutationGroup object. + * @returns A Promise that resolves to the CommitResponse, or null if buffered in an active transaction. + */ + async writeMutations( + mutations: + | GoogleProto.spanner.v1.BatchWriteRequest.IMutationGroup + | GoogleProto.spanner.v1.IMutation[] + ): Promise { + this.ensureOpenAndValid(); + + const mutationGroup = Array.isArray(mutations) + ? { mutations } + : (mutations as GoogleProto.spanner.v1.BatchWriteRequest.IMutationGroup); + const MutationGroupProto = + google.spanner.v1.BatchWriteRequest.MutationGroup; + const serializedPb = Buffer.from( + MutationGroupProto.encode(mutationGroup).finish() + ); + + const res = await ffi.invokeAsync( + 'WriteMutations', + this.pool!.oid, + this.oid, + serializedPb + ); + if (res.protobufBytes && res.protobufBytes.length > 0) { + return google.spanner.v1.CommitResponse.decode(res.protobufBytes); + } + return null; + } + + /** + * Executes a batch of DML statements on this connection. + * + * @param request An array of SQL strings/objects or ExecuteBatchDmlRequest object. + * @returns A Promise that resolves to the ExecuteBatchDmlResponse. + */ + async executeBatch( + request: + | string[] + | GoogleProto.spanner.v1.ExecuteBatchDmlRequest.IStatement[] + | GoogleProto.spanner.v1.IExecuteBatchDmlRequest + ): Promise { + this.ensureOpenAndValid(); + + let requestObj: GoogleProto.spanner.v1.IExecuteBatchDmlRequest; + if (Array.isArray(request)) { + const stmts = request.map((s) => + typeof s === 'string' + ? { sql: s } + : (s as GoogleProto.spanner.v1.ExecuteBatchDmlRequest.IStatement) + ); + requestObj = { statements: stmts }; + } else { + requestObj = request; + } + + const ExecuteBatchDmlRequestProto = + google.spanner.v1.ExecuteBatchDmlRequest; + const serializedPb = Buffer.from( + ExecuteBatchDmlRequestProto.encode(requestObj).finish() + ); + + const res = await ffi.invokeAsync( + 'ExecuteBatch', + this.pool!.oid, + this.oid, + serializedPb + ); + if (res.protobufBytes && res.protobufBytes.length > 0) { + return google.spanner.v1.ExecuteBatchDmlResponse.decode( + res.protobufBytes + ); + } + return { resultSets: [] }; + } + + /** + * Closes the connection and releases associated resources. + * + * @returns A Promise that resolves when the connection is closed. + */ + async close(): Promise { + if (!this.closed) { + this.closed = true; + try { + if ( + this.pool && + !this.pool.closed && + this.pool.oid !== null && + this.oid !== null + ) { + await ffi.invokeAsync('CloseConnection', this.pool.oid, this.oid); + } + } finally { + spannerLib.unregister(this); + } + } + } + + private ensureOpenAndValid(): void { + if (this.closed) { + throw new Error('Connection is already closed'); + } + if (!this.pool || this.pool.oid === null || this.oid === null) { + throw new Error('Connection is not bound to a Pool or invalid'); + } + } +} diff --git a/handwritten/spanner-driver/spannerlib-node/src/lib/pool.ts b/handwritten/spanner-driver/spannerlib-node/src/lib/pool.ts new file mode 100644 index 000000000000..ee5701d1edb5 --- /dev/null +++ b/handwritten/spanner-driver/spannerlib-node/src/lib/pool.ts @@ -0,0 +1,90 @@ +// 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 { ffi } from '../ffi/utils.js'; +import { spannerLib } from './spannerlib.js'; +import { Connection } from './connection.js'; + +/** + * Manages a pool of database connections to Spanner. + * + * This class wraps the connection pool handle from the underlying Go library, + * providing methods to create connections and manage the pool lifecycle. + */ +export class Pool { + public oid: number | null; + public closed: boolean; + public userAgent: string; + public connStr: string; + + /** + * Creates a new connection pool. + * + * @param connectionString The connection string for the database. + * @returns A Promise that resolves to a new Pool instance. + * @throws {SpannerLibError} If creation fails in the Go library. + */ + static async create(connectionString: string): Promise { + // Detect if running in ESM context + const isESM = typeof require === 'undefined'; + const userAgentSuffix = isESM ? 'node-esm' : 'node-cjs'; + + const p = new Pool(userAgentSuffix, connectionString); + const handled = await ffi.invokeAsync( + 'CreatePool', + userAgentSuffix, + connectionString + ); + + p.oid = handled.objectId; + spannerLib.register(p, { type: 'pool', poolId: p.oid! }); + return p; + } + + constructor(userAgent: string, connectionString: string) { + this.oid = null; + this.closed = false; + this.userAgent = userAgent; + this.connStr = connectionString; + } + + /** + * Creates a new connection from the pool. + * + * @returns A Promise that resolves to a new Connection instance. + * @throws {Error} If the pool is closed. + */ + async createConnection(): Promise { + if (this.closed) throw new Error('Pool is already closed'); + return await Connection.create(this); + } + + /** + * Closes the pool and releases associated resources. + * + * @returns A Promise that resolves when the pool is closed. + */ + async close(): Promise { + if (!this.closed) { + this.closed = true; + try { + if (this.oid !== null) { + await ffi.invokeAsync('ClosePool', this.oid); + } + } finally { + spannerLib.unregister(this); + } + } + } +} diff --git a/handwritten/spanner-driver/spannerlib-node/src/lib/rows.ts b/handwritten/spanner-driver/spannerlib-node/src/lib/rows.ts new file mode 100644 index 000000000000..d8fae09edfb5 --- /dev/null +++ b/handwritten/spanner-driver/spannerlib-node/src/lib/rows.ts @@ -0,0 +1,256 @@ +// 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 { ffi, ENCODING_PROTOBUF } from '../ffi/utils.js'; +import { spannerLib } from './spannerlib.js'; +import { Connection } from './connection.js'; +// TODO: Avoid tight coupling to internal paths of full client libraries. +// Unlike other languages like Java, Python , Node client does not export its protos. +// We need to explore how to import protos in Node +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; +const ListValue = google.protobuf.ListValue; + +/** + * An iterator over results returned by a SQL query. + * + * This class wraps the rows handle from the underlying Go library, + * providing methods to fetch rows one by one. + */ +export class Rows { + public connection: Connection; + public oid: number; + public closed: boolean; + private _cachedMetadata: googleProto.spanner.v1.ResultSetMetadata | null = + null; + private _cachedStats: googleProto.spanner.v1.ResultSetStats | null = null; + constructor(connection: Connection, oid: number) { + if ( + !connection.pool || + connection.pool.oid === null || + connection.pool.closed + ) { + throw new Error( + 'Cannot create Rows: Connection is not bound to an active Pool or Pool is closed' + ); + } + if (connection.closed || connection.oid === null) { + throw new Error('Cannot create Rows: Connection is closed or invalid'); + } + this.connection = connection; + this.oid = oid; + this.closed = false; + spannerLib.register(this, { + type: 'rows', + poolId: connection.pool!.oid!, + connectionId: connection.oid!, + rowsId: oid, + }); + } + + /** + * Fetches the next row of data. + * + * @returns {Promise} A Promise that resolves to a ListValue containing the row data, or null if there are no more rows. + * @throws {Error} If the rows are already closed. + * @throws {SpannerLibError} If fetching fails in the Go library. + */ + async next(): Promise { + this.ensureOpenAndValid(); + + const handled = await ffi.invokeAsync( + 'Next', + this.connection.pool!.oid!, + this.connection.oid!, + this.oid, + 1, + ENCODING_PROTOBUF + ); + + if (!handled.protobufBytes || handled.protobufBytes.length === 0) { + return null; + } + + return ListValue.decode(handled.protobufBytes); + } + + /** + * Retrieves the metadata for the result set. + * + * @returns {Promise} A Promise resolving to the ResultSetMetadata object. + */ + async metadata(): Promise { + this.ensureOpenAndValid(); + + if (this._cachedMetadata) { + return this._cachedMetadata; + } + + const handled = await ffi.invokeAsync( + 'Metadata', + this.connection.pool!.oid!, + this.connection.oid!, + this.oid + ); + + if (!handled.protobufBytes || handled.protobufBytes.length === 0) { + return null; + } + + this._cachedMetadata = google.spanner.v1.ResultSetMetadata.decode( + handled.protobufBytes + ); + return this._cachedMetadata; + } + + /** + * Retrieves the stats for the result set. + * + * @returns {Promise} A Promise resolving to the ResultSetStats object. + */ + async resultSetStats(): Promise { + this.ensureOpenAndValid(); + + if (this._cachedStats) { + return this._cachedStats; + } + + const handled = await ffi.invokeAsync( + 'ResultSetStats', + this.connection.pool!.oid!, + this.connection.oid!, + this.oid + ); + + if (!handled.protobufBytes || handled.protobufBytes.length === 0) { + return null; + } + + this._cachedStats = google.spanner.v1.ResultSetStats.decode( + handled.protobufBytes + ); + return this._cachedStats; + } + + /** + * Advances to the next result set (for multi-statement query results or batch DML). + * + * @returns {Promise} A Promise resolving to the next ResultSetMetadata if available, otherwise null. + */ + async nextResultSet(): Promise { + this.ensureOpenAndValid(); + + const handled = await ffi.invokeAsync( + 'NextResultSet', + this.connection.pool!.oid!, + this.connection.oid!, + this.oid + ); + + this._cachedMetadata = null; + this._cachedStats = null; + + if (!handled.protobufBytes || handled.protobufBytes.length === 0) { + return null; + } + + this._cachedMetadata = google.spanner.v1.ResultSetMetadata.decode( + handled.protobufBytes + ); + return this._cachedMetadata; + } + + /** + * Returns the exact or lower bound row/update count if present in ResultSetStats. + * + * @returns {Promise} A Promise resolving to the update count, or -1 if stats/count are not available. + */ + async updateCount(): Promise { + const stats = (await this.resultSetStats()) as { + rowCountExact?: unknown; + rowCountLowerBound?: unknown; + }; + if (!stats) { + return -1; + } + if (stats.rowCountExact !== undefined && stats.rowCountExact !== null) { + return typeof stats.rowCountExact === 'object' + ? Number(stats.rowCountExact.toString()) + : Number(stats.rowCountExact); + } + if ( + stats.rowCountLowerBound !== undefined && + stats.rowCountLowerBound !== null + ) { + return typeof stats.rowCountLowerBound === 'object' + ? Number(stats.rowCountLowerBound.toString()) + : Number(stats.rowCountLowerBound); + } + return -1; + } + + /** + * Closes the rows iterator and asynchronously releases the associated native resources. + * + * Marks the current iterator as closed, preventing subsequent `.next()` invocations, and dispatches an asynchronous `CloseRows` request to the native Go library via the C++ bridge layer. It finally unregisters the instance from the global garbage collection `FinalizationRegistry`. + * + * @returns {Promise} A Promise that resolves successfully once the background thread finishes the native release operation. + */ + async close(): Promise { + if (!this.closed) { + this.closed = true; + try { + if ( + this.connection.pool && + !this.connection.pool.closed && + !this.connection.closed && + this.connection.pool.oid !== null && + this.connection.oid !== null + ) { + await ffi.invokeAsync( + 'CloseRows', + this.connection.pool!.oid, + this.connection.oid, + this.oid + ); + } + } finally { + spannerLib.unregister(this); + } + } + } + + private ensureOpenAndValid(): void { + if (this.closed) { + throw new Error('Rows are already closed'); + } + if ( + !this.connection || + this.connection.closed || + this.connection.oid === null + ) { + throw new Error('Connection is closed or invalid'); + } + if ( + !this.connection.pool || + this.connection.pool.oid === null || + this.connection.pool.closed + ) { + throw new Error( + 'Connection must be bound to an active and open Pool to fetch rows' + ); + } + } +} diff --git a/handwritten/spanner-driver/spannerlib-node/src/lib/spannerlib.ts b/handwritten/spanner-driver/spannerlib-node/src/lib/spannerlib.ts new file mode 100644 index 000000000000..4710339fcb43 --- /dev/null +++ b/handwritten/spanner-driver/spannerlib-node/src/lib/spannerlib.ts @@ -0,0 +1,123 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { ffi } from '../ffi/utils.js'; + +export type ResourceCleanupInfo = + | { type: 'pool'; poolId: number } + | { type: 'connection'; poolId: number; connectionId: number } + | { type: 'rows'; poolId: number; connectionId: number; rowsId: number }; + +/** + * Internal registry and state manager for SpannerLib native resources. + * + * This class utilizes JavaScript's `FinalizationRegistry` to automatically + * trigger the cleanup of native Go resources (pools, connections, and rows) + * when their corresponding JavaScript wrapper objects are garbage collected + * by the V8 engine. This prevents native memory leaks even if explicit + * `.close()` calls are omitted. + * + * It also intercepts process exit signals to ensure all active Spanner pools + * and sessions are cleanly deleted from the GCP backend before termination. + */ +export class SpannerLib { + private registry: FinalizationRegistry; + private activeResources = new Set(); + private resourceMap = new WeakMap(); + + constructor() { + this.registry = new FinalizationRegistry((info: ResourceCleanupInfo) => { + this.activeResources.delete(info); + this.cleanup(info).catch(() => {}); + }); + + if (typeof process !== 'undefined') { + // Hook beforeExit for natural exit cleanup + process.once('beforeExit', () => { + this.cleanupAll().catch(() => {}); + }); + } + } + + private async cleanup(info: ResourceCleanupInfo): Promise { + switch (info.type) { + case 'pool': + await ffi.invokeAsync('ClosePool', info.poolId); + break; + case 'connection': + await ffi.invokeAsync( + 'CloseConnection', + info.poolId, + info.connectionId + ); + break; + case 'rows': + await ffi.invokeAsync( + 'CloseRows', + info.poolId, + info.connectionId, + info.rowsId + ); + break; + } + } + + public async cleanupAll(): Promise { + const resources = Array.from(this.activeResources); + this.activeResources.clear(); + this.resourceMap = new WeakMap(); + + const rows = resources.filter((r) => r.type === 'rows'); + const connections = resources.filter((r) => r.type === 'connection'); + const pools = resources.filter((r) => r.type === 'pool'); + + // Clean rows first, then connections, then pools in parallel chunks + await Promise.all( + rows.map((info) => + ffi + .invokeAsync('CloseRows', info.poolId, info.connectionId, info.rowsId) + .catch(() => {}) + ) + ); + await Promise.all( + connections.map((info) => + ffi + .invokeAsync('CloseConnection', info.poolId, info.connectionId) + .catch(() => {}) + ) + ); + await Promise.all( + pools.map((info) => + ffi.invokeAsync('ClosePool', info.poolId).catch(() => {}) + ) + ); + } + + register(refInstance: object, info: ResourceCleanupInfo): void { + this.activeResources.add(info); + this.resourceMap.set(refInstance, info); + this.registry.register(refInstance, info, refInstance); + } + + unregister(refInstance: object): void { + this.registry.unregister(refInstance); + const info = this.resourceMap.get(refInstance); + if (info) { + this.activeResources.delete(info); + this.resourceMap.delete(refInstance); + } + } +} + +export const spannerLib = new SpannerLib(); diff --git a/handwritten/spanner-driver/spannerlib-node/test/connection.test.ts b/handwritten/spanner-driver/spannerlib-node/test/connection.test.ts new file mode 100644 index 000000000000..7a1cc969f35d --- /dev/null +++ b/handwritten/spanner-driver/spannerlib-node/test/connection.test.ts @@ -0,0 +1,209 @@ +// 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 { Connection } from '../src/lib/connection.js'; +import { Pool } from '../src/lib/pool.js'; +import { ffi } from '../src/ffi/utils.js'; +import sinon from 'sinon'; + +describe('Connection', () => { + let stub: sinon.SinonStub; + + beforeEach(() => { + stub = sinon.stub(ffi, 'invokeAsync'); + }); + + afterEach(() => { + stub.restore(); + }); + + it('should execute SQL successfully', async () => { + // Mock Pool + const pool = new Pool( + 'node-esm', + 'projects/test/instances/test/databases/test' + ); + pool.oid = 1; + + const connection = new Connection(); + connection.pool = pool; + connection.oid = 2; + + // Mock invokeAsync response for Execute + stub + .onFirstCall() + .resolves({ objectId: 3, pinnerId: 0, protobufBytes: null }); + + const rows = await connection.execute('SELECT 1'); + + assert.ok(rows, 'Rows should be returned'); + assert.strictEqual(rows.oid, 3, 'Rows OID should match'); + assert.strictEqual( + rows.connection, + connection, + 'Rows connection should match' + ); + + assert.strictEqual( + stub.calledOnce, + true, + 'invokeAsync should be called once' + ); + assert.strictEqual( + stub.firstCall.args[0], + 'Execute', + 'First call should be Execute' + ); + }); + + it('should throw error if connection is closed', async () => { + const connection = new Connection(); + connection.closed = true; + + await assert.rejects(async () => { + await connection.execute('SELECT 1'); + }, /Connection is already closed/); + }); + + it('should throw error if pool is not bound', async () => { + const connection = new Connection(); + + await assert.rejects(async () => { + await connection.execute('SELECT 1'); + }, /Connection is not bound to a Pool/); + }); + + it('should begin transaction successfully', async () => { + const pool = new Pool( + 'node-esm', + 'projects/test/instances/test/databases/test' + ); + pool.oid = 1; + + const connection = new Connection(); + connection.pool = pool; + connection.oid = 2; + + stub.resolves({}); + await connection.beginTransaction(); + + assert.strictEqual(stub.calledOnce, true); + assert.strictEqual(stub.firstCall.args[0], 'BeginTransaction'); + }); + + it('should throw error on beginTransaction if connection is closed', async () => { + const connection = new Connection(); + connection.closed = true; + await assert.rejects(async () => { + await connection.beginTransaction(); + }, /Connection is already closed/); + }); + + it('should commit successfully', async () => { + const pool = new Pool( + 'node-esm', + 'projects/test/instances/test/databases/test' + ); + pool.oid = 1; + + const connection = new Connection(); + connection.pool = pool; + connection.oid = 2; + + stub.resolves({ protobufBytes: null }); + await connection.commit(); + + assert.strictEqual(stub.calledOnce, true); + assert.strictEqual(stub.firstCall.args[0], 'Commit'); + }); + + it('should throw error on commit if connection is closed', async () => { + const connection = new Connection(); + connection.closed = true; + await assert.rejects(async () => { + await connection.commit(); + }, /Connection is already closed/); + }); + + it('should rollback successfully', async () => { + const pool = new Pool( + 'node-esm', + 'projects/test/instances/test/databases/test' + ); + pool.oid = 1; + + const connection = new Connection(); + connection.pool = pool; + connection.oid = 2; + + stub.resolves({}); + await connection.rollback(); + + assert.strictEqual(stub.calledOnce, true); + assert.strictEqual(stub.firstCall.args[0], 'Rollback'); + }); + + it('should write mutations successfully', async () => { + const pool = new Pool( + 'node-esm', + 'projects/test/instances/test/databases/test' + ); + pool.oid = 1; + + const connection = new Connection(); + connection.pool = pool; + connection.oid = 2; + + stub.resolves({ protobufBytes: null }); + await connection.writeMutations([]); + + assert.strictEqual(stub.calledOnce, true); + assert.strictEqual(stub.firstCall.args[0], 'WriteMutations'); + }); + + it('should throw error on writeMutations if connection is closed', async () => { + const connection = new Connection(); + connection.closed = true; + await assert.rejects(async () => { + await connection.writeMutations([]); + }, /Connection is already closed/); + }); + + it('should execute batch DML successfully', async () => { + const pool = new Pool( + 'node-esm', + 'projects/test/instances/test/databases/test' + ); + pool.oid = 1; + + const connection = new Connection(); + connection.pool = pool; + connection.oid = 2; + + stub.resolves({ protobufBytes: null }); + await connection.executeBatch(['UPDATE T SET C=1']); + + assert.strictEqual(stub.calledOnce, true); + assert.strictEqual(stub.firstCall.args[0], 'ExecuteBatch'); + }); + + it('should throw error on executeBatch if connection is closed', async () => { + const connection = new Connection(); + connection.closed = true; + await assert.rejects(async () => { + await connection.executeBatch(['UPDATE T SET C=1']); + }, /Connection is already closed/); + }); +}); diff --git a/handwritten/spanner-driver/spannerlib-node/test/index.test.ts b/handwritten/spanner-driver/spannerlib-node/test/index.test.ts new file mode 100644 index 000000000000..5feb8ed97462 --- /dev/null +++ b/handwritten/spanner-driver/spannerlib-node/test/index.test.ts @@ -0,0 +1,36 @@ +// 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 { Pool, Connection, Rows } from '../src/index.js'; + +describe('SpannerLib Node Wrapper', () => { + it('should correctly export the primary interface classes', () => { + assert.ok(Pool, 'Pool class should be exported'); + assert.ok(Connection, 'Connection class should be exported'); + assert.ok(Rows, 'Rows class should be exported'); + }); + + it('should instantiate a Pool object properly', () => { + const pool = new Pool( + 'test-agent', + 'projects/test/instances/test/databases/test' + ); + assert.strictEqual(pool.userAgent, 'test-agent'); + assert.strictEqual( + pool.connStr, + 'projects/test/instances/test/databases/test' + ); + }); +}); diff --git a/handwritten/spanner-driver/spannerlib-node/test/mockserver/connection.test.ts b/handwritten/spanner-driver/spannerlib-node/test/mockserver/connection.test.ts new file mode 100644 index 000000000000..c0d50b8e845a --- /dev/null +++ b/handwritten/spanner-driver/spannerlib-node/test/mockserver/connection.test.ts @@ -0,0 +1,730 @@ +// 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. + +// Suppress no-explicit-any for decoding raw dynamic row structs returned by Spanner +/* eslint-disable @typescript-eslint/no-explicit-any */ +import * as assert from 'assert'; +import { Pool } from '../../src/lib/pool.js'; +import { + MockSpanner, + StatementResult, + createSelect1ResultSet, + createSelect1ResultSetWithStats, + createResultSetWithAllDataTypes, +} from './mockspanner.js'; +import { status } from '@grpc/grpc-js'; +import pkg from '@google-cloud/spanner/build/protos/protos.js'; +const { google } = pkg; + +describe('End-to-End Execution on MockServer', () => { + let mock: MockSpanner; + let port: number; + let pool: Pool; + + before(async () => { + mock = MockSpanner.create(); + port = await mock.listen('127.0.0.1:0'); + const connStr = `localhost:${port}/projects/p/instances/i/databases/d?useplaintext=true`; + pool = await Pool.create(connStr); + }); + + after(async () => { + if (pool) { + await pool.close(); + } + mock?.close(); + }); + + describe('Basic Operations', () => { + it('should execute SQL end-to-end and Row fetching', async () => { + mock.putStatementResult( + 'SELECT 1', + StatementResult.resultSet(createSelect1ResultSet()) + ); + + const connection = await pool.createConnection(); + assert.ok(connection, 'Connection created'); + + const rows = await connection.execute('SELECT 1'); + assert.ok(rows, 'Rows returned'); + + const row: any = await rows.next(); + assert.ok(row, 'Row decoded successfully'); + assert.strictEqual( + row.values[0].stringValue, + '1', + 'Decoded value matches result' + ); + assert.strictEqual(await rows.next(), null, 'Iterator complete'); + + await rows.close(); + await connection.close(); + }); + + it('should decode all Spanner data types correctly across the FFI boundary', async () => { + mock.putStatementResult( + 'SELECT * FROM AllTypes', + StatementResult.resultSet(createResultSetWithAllDataTypes()) + ); + + const connection = await pool.createConnection(); + const rows = await connection.execute('SELECT * FROM AllTypes'); + const row: any = await rows.next(); + assert.ok(row, 'Row decoded successfully'); + + assert.strictEqual(row.values[0].boolValue, true, 'BOOL decoded'); + assert.strictEqual(row.values[1].stringValue, '1', 'INT64 decoded'); + assert.strictEqual(row.values[2].numberValue, 3.14, 'FLOAT64 decoded'); + assert.strictEqual(row.values[4].stringValue, 'One', 'STRING decoded'); + + await rows.close(); + await connection.close(); + }); + + it('should properly handle backend gRPC errors and propagate them', async () => { + mock.putStatementResult( + 'SELECT * FROM InvalidTable', + StatementResult.error(new Error('Table InvalidTable not found')) + ); + + const connection = await pool.createConnection(); + await assert.rejects(async () => { + await connection.execute('SELECT * FROM InvalidTable'); + }, /InvalidTable/); + + await connection.close(); + }); + + it('should execute query with parameters successfully', async () => { + mock.putStatementResult( + 'SELECT FirstName FROM Singers WHERE SingerId = @id', + StatementResult.resultSet(createSelect1ResultSet()) + ); + + const connection = await pool.createConnection(); + const ExecuteSqlRequest = google.spanner.v1.ExecuteSqlRequest; + const rows = await connection.execute( + ExecuteSqlRequest.create({ + sql: 'SELECT FirstName FROM Singers WHERE SingerId = @id', + params: { fields: { id: { stringValue: '50' } } }, + paramTypes: { id: { code: google.spanner.v1.TypeCode.INT64 } }, + }) + ); + const row: any = await rows.next(); + assert.strictEqual(row.values[0].stringValue, '1'); + await rows.close(); + await connection.close(); + }); + + it('should execute client-side virtual statements (show/set)', async () => { + const connection = await pool.createConnection(); + assert.ok(connection); + + // Default variable check + let rows = await connection.execute( + 'SHOW VARIABLE retry_aborts_internally' + ); + assert.ok(rows); + let row: any = await rows.next(); + assert.ok(row); + assert.strictEqual(row.values[0].boolValue, true); + assert.strictEqual(await rows.next(), null); + await rows.close(); + + // Set variable + rows = await connection.execute('SET retry_aborts_internally = false'); + assert.ok(rows); + assert.strictEqual(await rows.next(), null); + await rows.close(); + + // Re-verify variable + rows = await connection.execute('SHOW VARIABLE retry_aborts_internally'); + assert.ok(rows); + row = await rows.next(); + assert.ok(row); + assert.strictEqual(row.values[0].boolValue, false); + assert.strictEqual(await rows.next(), null); + await rows.close(); + + await connection.close(); + }); + }); + + describe('Connection & Mutation Operations', () => { + it('should handle concurrent query execution across multiple connections', async () => { + mock.putStatementResult( + 'SELECT 1', + StatementResult.resultSet(createSelect1ResultSet()) + ); + + const connections = await Promise.all([ + pool.createConnection(), + pool.createConnection(), + pool.createConnection(), + ]); + + assert.strictEqual(connections.length, 3, '3 concurrent connections'); + + const results = await Promise.all( + connections.map((c) => c.execute('SELECT 1')) + ); + + for (const rows of results) { + const row: any = await rows.next(); + assert.strictEqual(row.values[0].stringValue, '1'); + await rows.close(); + } + + await Promise.all(connections.map((c) => c.close())); + }); + + it('should write mutations successfully', async () => { + const connection = await pool.createConnection(); + const Mutation = google.spanner.v1.Mutation; + const mutation = Mutation.create({ + insert: { + table: 'Users', + columns: ['id', 'name'], + values: [ + { values: [{ stringValue: '1' }, { stringValue: 'Alice' }] }, + ], + }, + }); + const res = await connection.writeMutations([mutation]); + assert.ok(res, 'CommitResponse returned'); + assert.ok(res.commitTimestamp, 'Commit timestamp present'); + await connection.close(); + }); + + it('should buffer mutations when an active transaction exists', async () => { + const connection = await pool.createConnection(); + await connection.beginTransaction(); + + const Mutation = google.spanner.v1.Mutation; + const mutation = Mutation.create({ + insert: { + table: 'Users', + columns: ['id', 'name'], + values: [{ values: [{ stringValue: '2' }, { stringValue: 'Bob' }] }], + }, + }); + const res = await connection.writeMutations([mutation]); + assert.strictEqual(res, null, 'Buffered mutation returns null/nil'); + + const commitResp = await connection.commit(); + assert.ok(commitResp, 'Commit response returned upon commit'); + assert.ok(commitResp.commitTimestamp, 'Commit timestamp present'); + + await connection.close(); + }); + + it('should throw error when writing mutations in a read-only transaction', async () => { + const connection = await pool.createConnection(); + assert.ok(connection); + + // Begin a Read-Only transaction (options: readOnly = {}) + await connection.beginTransaction({ readOnly: {} }); + + const Mutation = google.spanner.v1.Mutation; + const mutation = Mutation.create({ + insert: { + table: 'Users', + columns: ['id', 'name'], + values: [{ values: [{ stringValue: '2' }, { stringValue: 'Bob' }] }], + }, + }); + + await assert.rejects(async () => { + await connection.writeMutations([mutation]); + }, /read-only transactions/i); + + await connection.rollback(); + await connection.close(); + }); + }); + + describe('Transaction Operations', () => { + it('should manage explicit transaction lifecycle (beginTransaction, commit)', async () => { + mock.putStatementResult( + 'SELECT 1', + StatementResult.resultSet(createSelect1ResultSet()) + ); + + const connection = await pool.createConnection(); + await connection.beginTransaction(); + + const rows = await connection.execute('SELECT 1'); + const row: any = await rows.next(); + assert.strictEqual(row.values[0].stringValue, '1'); + await rows.close(); + + const commitResp = await connection.commit(); + assert.ok(commitResp, 'Commit response returned'); + assert.ok(commitResp.commitTimestamp, 'Commit timestamp present'); + + await connection.close(); + }); + + it('should manage explicit transaction rollback (beginTransaction, rollback)', async () => { + const connection = await pool.createConnection(); + await connection.beginTransaction(); + await connection.rollback(); + await connection.close(); + }); + + it('should execute read-only transaction successfully', async () => { + mock.putStatementResult( + 'SELECT 1', + StatementResult.resultSet(createSelect1ResultSet()) + ); + + const connection = await pool.createConnection(); + const TransactionOptions = google.spanner.v1.TransactionOptions; + await connection.beginTransaction( + TransactionOptions.create({ + readOnly: { strong: true }, + }) + ); + + const rows = await connection.execute('SELECT 1'); + const row: any = await rows.next(); + assert.strictEqual(row.values[0].stringValue, '1'); + await rows.close(); + + await connection.commit(); + + await connection.close(); + }); + + it('should throw error when calling beginTransaction twice', async () => { + const connection = await pool.createConnection(); + await connection.beginTransaction(); + await assert.rejects(async () => { + await connection.beginTransaction(); + }); + await connection.rollback(); + await connection.close(); + }); + }); + + describe('Batch Operations', () => { + it('should execute executeBatch successfully', async () => { + mock.putStatementResult( + 'UPDATE Users SET status = "ACTIVE" WHERE id = 1', + StatementResult.updateCount(1) + ); + + const connection = await pool.createConnection(); + const res = await connection.executeBatch([ + 'UPDATE Users SET status = "ACTIVE" WHERE id = 1', + ]); + assert.ok(res, 'ExecuteBatchDmlResponse returned'); + assert.strictEqual( + Number(res.resultSets![0].stats!.rowCountExact), + 1, + 'Row count matches' + ); + await connection.close(); + }); + + it('should execute query and batch DML using full Protobuf request objects', async () => { + mock.putStatementResult( + 'SELECT 1', + StatementResult.resultSet(createSelect1ResultSet()) + ); + mock.putStatementResult( + 'UPDATE Users SET status = "ACTIVE" WHERE id = 1', + StatementResult.updateCount(1) + ); + + const connection = await pool.createConnection(); + + const ExecuteSqlRequest = google.spanner.v1.ExecuteSqlRequest; + const rows = await connection.execute( + ExecuteSqlRequest.create({ sql: 'SELECT 1' }) + ); + const row: any = await rows.next(); + assert.strictEqual(row.values[0].stringValue, '1'); + await rows.close(); + + const ExecuteBatchDmlRequest = google.spanner.v1.ExecuteBatchDmlRequest; + const res = await connection.executeBatch( + ExecuteBatchDmlRequest.create({ + statements: [ + { sql: 'UPDATE Users SET status = "ACTIVE" WHERE id = 1' }, + ], + }) + ); + assert.strictEqual(Number(res.resultSets![0].stats!.rowCountExact), 1); + + await connection.close(); + }); + }); + + describe('Rows & Result Set Operations', () => { + it('should retrieve metadata from Rows object', async () => { + mock.putStatementResult( + 'SELECT 1', + StatementResult.resultSet(createSelect1ResultSet()) + ); + + const connection = await pool.createConnection(); + const rows = await connection.execute('SELECT 1'); + assert.ok(rows); + + const metadata = await rows.metadata(); + assert.ok(metadata); + assert.ok(metadata.rowType); + assert.strictEqual(metadata.rowType.fields!.length, 1); + + await rows.close(); + await connection.close(); + }); + + it('should retrieve stats and updateCount from Rows object', async () => { + mock.putStatementResult( + 'UPDATE Singers SET Name = "Bob"', + StatementResult.updateCount(100) + ); + + const connection = await pool.createConnection(); + const rows = await connection.execute('UPDATE Singers SET Name = "Bob"'); + assert.ok(rows); + + while ((await rows.next()) !== null) { + // Consume all rows + } + + const stats = await rows.resultSetStats(); + assert.ok(stats); + assert.strictEqual(Number(stats.rowCountExact), 100); + + const count = await rows.updateCount(); + assert.strictEqual(count, 100); + + await rows.close(); + await connection.close(); + }); + + it('should retrieve stats from a SELECT query after consuming all rows', async () => { + mock.putStatementResult( + 'SELECT 1', + StatementResult.resultSet(createSelect1ResultSetWithStats()) + ); + + const connection = await pool.createConnection(); + const rows = await connection.execute({ + sql: 'SELECT 1', + queryMode: google.spanner.v1.ExecuteSqlRequest.QueryMode.PROFILE, + }); + assert.ok(rows); + + while ((await rows.next()) !== null) { + // Consume all rows + } + + const stats = await rows.resultSetStats(); + assert.ok(stats); + assert.ok(stats.queryStats); + assert.ok(stats.queryStats.fields); + assert.strictEqual( + stats.queryStats.fields['elapsed_time']?.stringValue, + '1ms' + ); + + await rows.close(); + await connection.close(); + }); + + it('should advance to next result set using nextResultSet()', async () => { + mock.putStatementResult( + 'SELECT 1', + StatementResult.resultSet(createSelect1ResultSet()) + ); + mock.putStatementResult( + 'SELECT 2', + StatementResult.resultSet(createSelect2ResultSet()) + ); + + const connection = await pool.createConnection(); + const rows = await connection.execute('SELECT 1; SELECT 2'); + assert.ok(rows); + + // Consume first result set + const row1 = await rows.next(); + assert.ok(row1); + assert.strictEqual(row1.values[0].stringValue, '1'); + assert.strictEqual(await rows.next(), null); // End of first result set + + // Move to next result set + const meta2 = await rows.nextResultSet(); + assert.ok(meta2); + assert.ok(meta2.rowType); + assert.strictEqual(meta2.rowType.fields![0].name, 'COL2'); + + // Consume second result set + const row2 = await rows.next(); + assert.ok(row2); + assert.strictEqual(row2.values[0].stringValue, '2'); + assert.strictEqual(await rows.next(), null); // End of second result set + + // There should be no more result sets + assert.strictEqual(await rows.nextResultSet(), null); + + await rows.close(); + await connection.close(); + }); + + it('should return null when calling nextResultSet() on a single query', async () => { + mock.putStatementResult( + 'SELECT 1', + StatementResult.resultSet(createSelect1ResultSet()) + ); + + const connection = await pool.createConnection(); + const rows = await connection.execute('SELECT 1'); + assert.ok(rows); + + while ((await rows.next()) !== null) { + // Consume all rows + } + + assert.strictEqual(await rows.nextResultSet(), null); + + await rows.close(); + await connection.close(); + }); + + it('should clear and update stats when moving to next result set', async () => { + mock.putStatementResult( + 'UPDATE Singers SET FirstName = "A" WHERE SingerId = 1', + StatementResult.updateCount(10) + ); + mock.putStatementResult( + 'UPDATE Singers SET FirstName = "B" WHERE SingerId = 2', + StatementResult.updateCount(20) + ); + + const connection = await pool.createConnection(); + const rows = await connection.execute( + 'UPDATE Singers SET FirstName = "A" WHERE SingerId = 1; UPDATE Singers SET FirstName = "B" WHERE SingerId = 2' + ); + assert.ok(rows); + + while ((await rows.next()) !== null) { + // Consume all rows + } + + const stats1 = await rows.resultSetStats(); + assert.ok(stats1); + assert.strictEqual(Number(stats1.rowCountExact), 10); + + const meta2 = await rows.nextResultSet(); + assert.ok(meta2); + + while ((await rows.next()) !== null) { + // Consume all rows + } + + const stats2 = await rows.resultSetStats(); + assert.ok(stats2); + assert.strictEqual(Number(stats2.rowCountExact), 20); + + await rows.close(); + await connection.close(); + }); + + it('should advance through mixed query and DML result sets in a multi-statement execution', async () => { + mock.putStatementResult( + 'SELECT 1', + StatementResult.resultSet(createSelect1ResultSet()) + ); + mock.putStatementResult( + 'UPDATE Singers SET FirstName = "A" WHERE SingerId = 1', + StatementResult.updateCount(5) + ); + mock.putStatementResult( + 'SELECT 2', + StatementResult.resultSet(createSelect2ResultSet()) + ); + + const connection = await pool.createConnection(); + const rows = await connection.execute( + 'SELECT 1; UPDATE Singers SET FirstName = "A" WHERE SingerId = 1; SELECT 2' + ); + assert.ok(rows); + + // Consume first result set: SELECT 1 + const row1 = await rows.next(); + assert.ok(row1); + assert.strictEqual(row1.values[0].stringValue, '1'); + const endRow = await rows.next(); + assert.strictEqual(endRow, null); // End of first result set + + // Move to second result set: UPDATE Singers + const meta2 = await rows.nextResultSet(); + assert.ok(meta2); + + // DML has no rows to fetch + const endRowDml = await rows.next(); + assert.strictEqual(endRowDml, null); + + // Verify DML stats + const stats2 = await rows.resultSetStats(); + assert.ok(stats2); + assert.strictEqual(Number(stats2.rowCountExact), 5); + + // Move to third result set: SELECT 2 + const meta3 = await rows.nextResultSet(); + assert.ok(meta3); + assert.ok(meta3.rowType); + assert.strictEqual(meta3.rowType.fields![0].name, 'COL2'); + + // Consume third result set: SELECT 2 + const row3 = await rows.next(); + assert.ok(row3); + assert.strictEqual(row3.values[0].stringValue, '2'); + assert.strictEqual(await rows.next(), null); // End of third result set + + // There should be no more result sets + const meta4 = await rows.nextResultSet(); + assert.strictEqual(meta4, null); + + await rows.close(); + await connection.close(); + }); + + it('should handle multi-statement execution containing errors', async () => { + mock.putStatementResult( + 'SELECT 1', + StatementResult.resultSet(createSelect1ResultSet()) + ); + // mock an error for unknown table + const tableNotFoundError = new Error('Table not found') as any; + tableNotFoundError.code = status.NOT_FOUND; + mock.putStatementResult( + 'SELECT * FROM UnknownTable', + StatementResult.error(tableNotFoundError) + ); + + const connection = await pool.createConnection(); + const rows = await connection.execute( + 'SELECT 1; SELECT * FROM UnknownTable' + ); + assert.ok(rows); + + // First query (SELECT 1) succeeds + const row1 = await rows.next(); + assert.ok(row1); + assert.strictEqual(row1.values[0].stringValue, '1'); + assert.strictEqual(await rows.next(), null); + + // Advancing to the next result set (SELECT * FROM UnknownTable) should throw NotFound error + await assert.rejects(async () => { + await rows.nextResultSet(); + }, /Table not found/i); + + await rows.close(); + await connection.close(); + }); + + it('should fail fetching next row if connection is closed midway', async () => { + mock.putStatementResult( + 'SELECT * FROM Singers', + StatementResult.resultSet(createResultSetWithManyRows()) + ); + + const connection = await pool.createConnection(); + const rows = await connection.execute('SELECT * FROM Singers'); + assert.ok(rows); + + // Fetch first row + const row1 = await rows.next(); + assert.ok(row1); + + // Close connection + await connection.close(); + + // Fetching subsequent rows should throw connection closed/invalid error + await assert.rejects(async () => { + await rows.next(); + }, /Connection is closed or invalid/i); + + await rows.close(); + }); + + it('should close connection with open rows cleanly without internal errors', async () => { + mock.putStatementResult( + 'SELECT * FROM Singers', + StatementResult.resultSet(createResultSetWithManyRows()) + ); + + const connection = await pool.createConnection(); + const rows = await connection.execute('SELECT * FROM Singers'); + assert.ok(rows); + + // Fetch first row + const row1 = await rows.next(); + assert.ok(row1); + + // Close the connection while rows are still open + await connection.close(); + + // Attempting to read more rows should cleanly fail with Connection is closed or invalid + await assert.rejects(async () => { + await rows.next(); + }, /Connection is closed or invalid/i); + }); + }); +}); + +function createResultSetWithManyRows() { + const fields = [ + google.spanner.v1.StructType.Field.create({ + name: 'SingerId', + type: { code: google.spanner.v1.TypeCode.INT64 }, + }), + ]; + const metadata = new google.spanner.v1.ResultSetMetadata({ + rowType: new google.spanner.v1.StructType({ + fields, + }), + }); + return google.spanner.v1.ResultSet.create({ + metadata, + rows: [ + { values: [{ stringValue: '1' }] }, + { values: [{ stringValue: '2' }] }, + { values: [{ stringValue: '3' }] }, + ], + }); +} + +function createSelect2ResultSet() { + const fields = [ + google.spanner.v1.StructType.Field.create({ + name: 'COL2', + type: { code: google.spanner.v1.TypeCode.INT64 }, + }), + ]; + const metadata = new google.spanner.v1.ResultSetMetadata({ + rowType: new google.spanner.v1.StructType({ + fields, + }), + }); + return google.spanner.v1.ResultSet.create({ + metadata, + rows: [{ values: [{ stringValue: '2' }] }], + }); +} diff --git a/handwritten/spanner-driver/spannerlib-node/test/mockserver/mockspanner.ts b/handwritten/spanner-driver/spannerlib-node/test/mockserver/mockspanner.ts new file mode 100644 index 000000000000..0e1b6c0f07a2 --- /dev/null +++ b/handwritten/spanner-driver/spannerlib-node/test/mockserver/mockspanner.ts @@ -0,0 +1,1479 @@ +// 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. + +// Suppressions justification: +// 1. no-explicit-any: Required for handling dynamic gRPC/Protobuf wire payloads. +// 2. no-namespace: Required for declaring ambient protobuf namespace exports. +// 3. ban-ts-comment: Required for @ts-ignore before import.meta across dual ESM/CJS targets. +/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-namespace, @typescript-eslint/ban-ts-comment */ +import * as path from 'path'; +import { grpc as gaxGrpc } from 'google-gax'; +import * as protoLoader from '@grpc/proto-loader'; +import { createRequire } from 'module'; +import { Metadata, Server, ServerCredentials } from '@grpc/grpc-js'; +import pkg from '@google-cloud/spanner/build/protos/protos.js'; +import type { google as gNamespace } from '@google-cloud/spanner/build/protos/protos.js'; + +const { google } = pkg as any; +const spannerProto = google.spanner.v1; +const TimestampProto = google.protobuf.Timestamp; +const RetryInfoProto = google.rpc.RetryInfo; +const StatusProto = google.rpc.Status; +const AnyProto = google.protobuf.Any; + +declare namespace protobuf { + export type ResultSet = gNamespace.spanner.v1.ResultSet; + export type PartialResultSet = gNamespace.spanner.v1.PartialResultSet; + export type Session = gNamespace.spanner.v1.Session; + export type Transaction = gNamespace.spanner.v1.Transaction; + export type ITransactionOptions = gNamespace.spanner.v1.ITransactionOptions; + export type ResultSetMetadata = gNamespace.spanner.v1.ResultSetMetadata; +} + +type Timestamp = gNamespace.protobuf.Timestamp; +type ResultSet = gNamespace.spanner.v1.ResultSet; +type Status = gNamespace.rpc.Status; +const QueryMode = spannerProto.ExecuteSqlRequest.QueryMode; +type ReadRequest = gNamespace.spanner.v1.ReadRequest; +type ServiceError = gaxGrpc.ServiceError; + +const customRequire = + // @ts-ignore + typeof require !== 'undefined' ? require : createRequire(import.meta.url); + +const PROTO_PATH = customRequire.resolve( + '@google-cloud/spanner/build/protos/google/spanner/v1/spanner.proto' +); +const IMPORT_PATH = path.resolve(path.dirname(PROTO_PATH), '../../..'); +const GAX_PROTO_DIR = path.join( + path.dirname(customRequire.resolve('google-gax')), + '..', + 'protos' +); + +const packageDefinition = protoLoader.loadSync(PROTO_PATH, { + keepCase: false, + longs: String, + enums: String, + defaults: true, + oneofs: true, + includeDirs: [IMPORT_PATH, GAX_PROTO_DIR], +}); +const protoDescriptor = gaxGrpc.loadPackageDefinition(packageDefinition) as any; +const spannerProtoDescriptor = protoDescriptor['google']['spanner']['v1']; +const RETRY_INFO_BIN = 'google.rpc.retryinfo-bin'; +const RETRY_INFO_TYPE = 'type.googleapis.com/google.rpc.retryinfo'; + +export enum ReadRequestResultType { + ERROR, + RESULT_SET, +} + +export enum StatementResultType { + ERROR, + RESULT_SET, + UPDATE_COUNT, +} + +type ResultSetUnion = protobuf.ResultSet | protobuf.PartialResultSet[]; + +export class ReadRequestResult { + private readonly _type: ReadRequestResultType; + + get type(): ReadRequestResultType { + return this._type; + } + + private readonly _error: Error | null; + + get error(): Error { + if (this._error) { + return this._error; + } + throw new Error('The ReadRequestResult does not contain an Error'); + } + + private readonly _resultSet: ResultSetUnion | null; + + get resultSet(): ResultSetUnion { + if (this._resultSet) { + return this._resultSet; + } + throw new Error('The ReadRequestResult does not contain a ResultSet'); + } + + private constructor( + type: ReadRequestResultType, + error: Error | null, + resultSet: protobuf.ResultSet | protobuf.PartialResultSet[] | null + ) { + this._type = type; + this._error = error; + this._resultSet = resultSet; + } + + static error(error: Error): ReadRequestResult { + return new ReadRequestResult(ReadRequestResultType.ERROR, error, null); + } + + static resultSet( + resultSet: protobuf.ResultSet | protobuf.PartialResultSet[] + ): ReadRequestResult { + return new ReadRequestResult( + ReadRequestResultType.RESULT_SET, + null, + resultSet + ); + } +} + +export class StatementResult { + private readonly _type: StatementResultType; + get type(): StatementResultType { + return this._type; + } + private readonly _error: Error | null; + get error(): Error { + if (this._error) { + return this._error; + } + throw new Error('The StatementResult does not contain an Error'); + } + private readonly _resultSet: ResultSetUnion | null; + get resultSet(): ResultSetUnion { + if (this._resultSet) { + return this._resultSet; + } + throw new Error('The StatementResult does not contain a ResultSet'); + } + private readonly _updateCount: number | null; + get updateCount(): number { + if (this._updateCount !== null) { + return this._updateCount; + } + throw new Error('The StatementResult does not contain an UpdateCount'); + } + + private constructor( + type: StatementResultType, + error: Error | null, + resultSet: protobuf.ResultSet | protobuf.PartialResultSet[] | null, + updateCount: number | null + ) { + this._type = type; + this._error = error; + this._resultSet = resultSet; + this._updateCount = updateCount; + } + + static error(error: Error): StatementResult { + return new StatementResult(StatementResultType.ERROR, error, null, null); + } + + static resultSet( + resultSet: protobuf.ResultSet | protobuf.PartialResultSet[] + ): StatementResult { + return new StatementResult( + StatementResultType.RESULT_SET, + null, + resultSet, + null + ); + } + + static updateCount(updateCount: number, error?: Error): StatementResult { + return new StatementResult( + StatementResultType.UPDATE_COUNT, + error || null, + null, + updateCount + ); + } +} + +export interface MockError extends ServiceError { + streamIndex?: number; +} + +export class SimulatedExecutionTime { + private readonly _minimumExecutionTime?: number; + get minimumExecutionTime(): number | undefined { + return this._minimumExecutionTime; + } + private readonly _randomExecutionTime?: number; + get randomExecutionTime(): number | undefined { + return this._randomExecutionTime; + } + private readonly _errors?: ServiceError[]; + get errors(): MockError[] | undefined { + return this._errors; + } + private readonly _keepError?: boolean; + + private constructor(input: { + minimumExecutionTime?: number; + randomExecutionTime?: number; + errors?: ServiceError[]; + keepError?: boolean; + }) { + this._minimumExecutionTime = input.minimumExecutionTime; + this._randomExecutionTime = input.randomExecutionTime; + this._errors = input.errors; + this._keepError = input.keepError; + } + + static ofError(error: MockError): SimulatedExecutionTime { + return new SimulatedExecutionTime({ errors: [error] }); + } + + static ofErrors(errors: MockError[]): SimulatedExecutionTime { + return new SimulatedExecutionTime({ errors }); + } + + static ofMinAndRandomExecTime(minExecTime: number, randomExecTime: number) { + return new SimulatedExecutionTime({ + minimumExecutionTime: minExecTime, + randomExecutionTime: randomExecTime, + }); + } + + async simulateExecutionTime() { + if (!(this.randomExecutionTime || this.minimumExecutionTime)) { + return; + } + const rnd = this.randomExecutionTime + ? Math.random() * this.randomExecutionTime + : 0; + const total = + (this.minimumExecutionTime ? this.minimumExecutionTime : 0) + rnd; + await MockSpanner.sleep(total); + } +} + +export function createUnimplementedError(msg: string): ServiceError { + const error = new Error(msg); + return Object.assign(error, { + code: gaxGrpc.status.UNIMPLEMENTED, + }) as ServiceError; +} + +interface Request {} + +export class MockSpanner { + private requests: Request[] = []; + private metadata: Metadata[] = []; + private frozen = 0; + private sessionCounter = 0; + private sessions: Map = new Map(); + private mutationOnly: boolean; + private transactionSeqNum: Map = new Map(); + private transactionCounters: Map = new Map(); + private transactions: Map = new Map(); + private transactionOptions: Map< + string, + protobuf.ITransactionOptions | null | undefined + > = new Map(); + private abortedTransactions: Set = new Set(); + private statementResults: Map = new Map(); + private readRequestResults: Map = new Map(); + private executionTimes: Map = new Map(); + private server?: Server; + private port = 0; + + private constructor() { + this.putStatementResult = this.putStatementResult.bind(this); + this.putReadRequestResult = this.putReadRequestResult.bind(this); + this.batchCreateSessions = this.batchCreateSessions.bind(this); + this.createSession = this.createSession.bind(this); + this.deleteSession = this.deleteSession.bind(this); + this.getSession = this.getSession.bind(this); + this.listSessions = this.listSessions.bind(this); + + this.beginTransaction = this.beginTransaction.bind(this); + this.commit = this.commit.bind(this); + this.rollback = this.rollback.bind(this); + + this.executeBatchDml = this.executeBatchDml.bind(this); + this.executeStreamingSql = this.executeStreamingSql.bind(this); + this.partitionQuery = this.partitionQuery.bind(this); + + this.read = this.read.bind(this); + this.streamingRead = this.streamingRead.bind(this); + this.partitionRead = this.partitionRead.bind(this); + this.batchWrite = this.batchWrite.bind(this); + this.mutationOnly = false; + } + + static create(): MockSpanner { + return new MockSpanner(); + } + + async listen(address = '127.0.0.1:0'): Promise { + this.server = new Server(); + createMockSpanner(this.server, this); + return new Promise((resolve, reject) => { + this.server!.bindAsync( + address, + ServerCredentials.createInsecure(), + (err, boundPort) => { + if (err) return reject(err); + this.port = boundPort; + resolve(this.port); + } + ); + }); + } + + getPort(): number { + return this.port; + } + + close() { + this.server?.forceShutdown(); + } + + resetRequests(): void { + this.requests = []; + this.metadata = []; + } + + getRequests(): Request[] { + return this.requests; + } + + getMetadata(): Metadata[] { + return this.metadata; + } + + putReadRequestResult(query: ReadRequest, result: ReadRequestResult) { + const keySet = JSON.stringify( + query.keySet ?? {}, + Object.keys(query.keySet ?? {}).sort() + ); + const key = `${query.table}|${keySet}`; + this.readRequestResults.set(key, result); + } + + putStatementResult(sql: string, result: StatementResult) { + this.statementResults.set(sql.trim(), result); + } + + removeExecutionTime(fn: (...args: any[]) => any) { + this.executionTimes.delete(fn.name); + } + + setExecutionTime(fn: (...args: any[]) => any, time: SimulatedExecutionTime) { + this.executionTimes.set(fn.name, time); + } + + removeExecutionTimes() { + this.executionTimes.clear(); + } + + abortTransaction(transaction: any): void { + const formattedId = `${transaction.session?.formattedName_ ?? transaction.session}/transactions/${transaction.id}`; + if (this.transactions.has(formattedId) || !transaction.id) { + this.transactions.delete(formattedId); + this.transactionOptions.delete(formattedId); + this.abortedTransactions.add(formattedId); + } else { + throw new Error(`Transaction ${formattedId} does not exist`); + } + } + + freeze() { + this.frozen++; + } + + unfreeze() { + if (this.frozen === 0) { + throw new Error('This mock server is already unfrozen'); + } + this.frozen--; + } + + private newSession( + database: string, + multiplexed?: boolean + ): protobuf.Session { + const id = this.sessionCounter++; + const name = `${database}/sessions/${id}`; + const session = spannerProto.Session.create({ + name, + multiplexed: multiplexed, + createTime: now(), + }); + this.sessions.set(name, session); + return session; + } + + private static createSessionNotFoundError(name: string): ServiceError { + const error = new Error(`Session not found: ${name}`); + return Object.assign(error, { + code: gaxGrpc.status.NOT_FOUND, + }) as ServiceError; + } + + private static createTransactionNotFoundError(name: string): ServiceError { + const error = new Error(`Transaction not found: ${name}`); + return Object.assign(error, { + code: gaxGrpc.status.NOT_FOUND, + }) as ServiceError; + } + + private static createTransactionAbortedError(name: string): ServiceError { + const error = Object.assign(new Error(`Transaction aborted: ${name}`), { + code: gaxGrpc.status.ABORTED, + }); + return Object.assign(error, { + metadata: this.createMinimalRetryDelayMetadata(), + }) as ServiceError; + } + + static createMinimalRetryDelayMetadata(): Metadata { + const metadata = new Metadata(); + const retry = RetryInfoProto.encode({ + retryDelay: { + seconds: 0, + nanos: 1, + }, + }); + metadata.add(RETRY_INFO_BIN, Buffer.from(retry.finish())); + return metadata; + } + + static sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + + private async simulateExecutionTime(functionName: string): Promise { + while (this.frozen > 0) { + await MockSpanner.sleep(10); + } + const execTime = this.executionTimes.get(functionName); + if (execTime) { + await execTime.simulateExecutionTime(); + } + if ( + execTime && + execTime.errors && + execTime.errors.length && + !execTime.errors[0].streamIndex + ) { + throw execTime.errors.shift(); + } + } + + private shiftStreamError( + functionName: string, + index: number + ): MockError | undefined { + const execTime = this.executionTimes.get(functionName); + if (execTime && execTime.errors && execTime.errors.length) { + if (execTime.errors[0].streamIndex === index) { + return execTime.errors.shift(); + } + } + return undefined; + } + + private pushRequest(request: Request, metadata: Metadata): void { + this.requests.push(request); + this.metadata.push(metadata); + } + + batchCreateSessions(call: any, callback: any) { + this.pushRequest(call.request!, call.metadata); + this.simulateExecutionTime(this.batchCreateSessions.name) + .then(() => { + const sessions = new Array(); + for (let i = 0; i < call.request!.sessionCount; i++) { + sessions.push(this.newSession(call.request!.database)); + } + callback( + null, + spannerProto.BatchCreateSessionsResponse.create({ session: sessions }) + ); + }) + .catch((err) => { + callback(err); + }); + } + + createSession(call: any, callback: any) { + this.pushRequest(call.request!, call.metadata); + this.simulateExecutionTime(this.createSession.name) + .then(() => { + callback( + null, + this.newSession( + call.request!.database, + call.request!.session?.multiplexed ?? false + ) + ); + }) + .catch((err) => { + callback(err); + }); + } + + getSession(call: any, callback: any) { + this.pushRequest(call.request!, call.metadata); + this.simulateExecutionTime(this.getSession.name) + .then(() => { + const session = this.sessions.get(call.request!.name); + if (session) { + callback(null, session); + } else { + callback(MockSpanner.createSessionNotFoundError(call.request!.name)); + } + }) + .catch((err) => { + callback(err); + }); + } + + listSessions(call: any, callback: any) { + this.pushRequest(call.request!, call.metadata); + this.simulateExecutionTime(this.listSessions.name) + .then(() => { + callback( + null, + spannerProto.ListSessionsResponse.create({ + sessions: Array.from(this.sessions.values()).filter((session) => { + return session.name.startsWith(call.request!.database); + }), + }) + ); + }) + .catch((err) => { + callback(err); + }); + } + + deleteSession(call: any, callback: any) { + this.pushRequest(call.request!, call.metadata); + if (this.sessions.delete(call.request!.name)) { + callback(null, google.protobuf.Empty.create()); + } else { + callback(MockSpanner.createSessionNotFoundError(call.request!.name)); + } + } + + executeSql(call: any, callback: any) { + this.pushRequest(call.request!, call.metadata); + const sql = call.request.sql?.trim(); + let res = this.statementResults.get(sql); + if (!res && sql?.toLowerCase().includes('database_dialect')) { + res = StatementResult.resultSet(createDialectResultSet()); + } + if (!res) { + return callback( + createUnimplementedError(`No result registered for query: ${sql}`) + ); + } + if (res.type === StatementResultType.RESULT_SET) { + callback(null, res.resultSet); + } else if (res.type === StatementResultType.ERROR) { + callback(res.error); + } + } + + executeStreamingSql(call: any) { + this.pushRequest(call.request!, call.metadata); + this.simulateExecutionTime(this.executeStreamingSql.name) + .then(() => { + let transactionKey; + if (call.request!.transaction) { + const fullTransactionId = `${call.request!.session}/transactions/${ + call.request!.transaction.id + }`; + transactionKey = fullTransactionId; + if (this.abortedTransactions.has(fullTransactionId)) { + call.sendMetadata(new Metadata()); + call.emit( + 'error', + MockSpanner.createTransactionAbortedError(`${fullTransactionId}`) + ); + call.end(); + return; + } + } + let res = this.statementResults.get(call.request!.sql?.trim()); + if ( + !res && + call.request!.sql?.toLowerCase().includes('database_dialect') + ) { + res = StatementResult.resultSet(createDialectResultSet()); + } + const session = this.sessions.get(call.request!.session); + let txn: any = null; + if (res) { + if (call.request!.transaction?.begin) { + const resultTxn = this._updateTransaction( + call.request!.session, + call.request!.transaction.begin + ); + if (resultTxn instanceof Error) { + call.sendMetadata(new Metadata()); + call.emit('error', resultTxn); + call.end(); + return; + } + txn = resultTxn; + transactionKey = `${call.request!.session}/transactions/${txn.id.toString()}`; + if (res.type === StatementResultType.RESULT_SET) { + (res.resultSet as protobuf.ResultSet).metadata!.transaction = txn; + } + } + + const currentSeqNum = + this.transactionSeqNum.get(transactionKey!) || 0; + const nextSeqNum = currentSeqNum + 1; + this.transactionSeqNum.set(transactionKey!, nextSeqNum); + + const precommitToken = session?.multiplexed + ? spannerProto.MultiplexedSessionPrecommitToken.create({ + precommitToken: Buffer.from('mock-precommit-token'), + seqNum: nextSeqNum, + }) + : null; + let partialResultSets: any[]; + let resumeIndex: number; + let streamErr: MockError | undefined; + switch (res.type) { + case StatementResultType.RESULT_SET: + if ((res.resultSet as any).precommitToken !== undefined) { + (res.resultSet as protobuf.ResultSet).precommitToken = + precommitToken; + } + if (Array.isArray(res.resultSet)) { + partialResultSets = res.resultSet; + } else { + partialResultSets = MockSpanner.toPartialResultSets( + res.resultSet as any, + call.request!.queryMode + ); + } + resumeIndex = + call.request!.resumeToken.length === 0 + ? 0 + : Number.parseInt(call.request!.resumeToken.toString(), 10) + + 1; + for ( + let index = resumeIndex; + index < partialResultSets.length; + index++ + ) { + streamErr = this.shiftStreamError( + this.executeStreamingSql.name, + index + ); + if (streamErr) { + call.sendMetadata(new Metadata()); + call.emit('error', streamErr); + break; + } + call.write(partialResultSets[index]); + } + break; + case StatementResultType.UPDATE_COUNT: { + const metadata = new spannerProto.ResultSetMetadata({ + rowType: new spannerProto.StructType({}), + }); + if (txn) { + metadata.transaction = txn; + } + call.write( + MockSpanner.emptyPartialResultSet( + precommitToken, + Buffer.from('1'.padStart(8, '0')), + metadata + ) + ); + streamErr = this.shiftStreamError( + this.executeStreamingSql.name, + 1 + ); + if (streamErr) { + call.sendMetadata(new Metadata()); + call.emit('error', streamErr); + break; + } + call.write( + MockSpanner.toPartialResultSet(precommitToken, res.updateCount) + ); + break; + } + case StatementResultType.ERROR: + call.sendMetadata(new Metadata()); + call.emit('error', res.error); + break; + default: + call.emit( + 'error', + new Error(`Unknown StatementResult type: ${res.type}`) + ); + } + } else { + call.emit( + 'error', + new Error(`There is no result registered for ${call.request!.sql}`) + ); + } + call.end(); + }) + .catch((err) => { + call.sendMetadata(new Metadata()); + call.emit('error', err); + call.end(); + }); + } + + private static toPartialResultSets( + resultSet: protobuf.ResultSet, + queryMode: any, + rowsPerPartialResultSet = 1 + ): protobuf.PartialResultSet[] { + const res: protobuf.PartialResultSet[] = []; + let first = true; + for (let i = 0; i < resultSet.rows.length; i += rowsPerPartialResultSet) { + const token = i.toString().padStart(8, '0'); + const partial = spannerProto.PartialResultSet.create({ + resumeToken: Buffer.from(token), + values: [], + precommitToken: resultSet.precommitToken, + }); + for ( + let row = i; + row < Math.min(i + rowsPerPartialResultSet, resultSet.rows.length); + row++ + ) { + partial.values.push(...resultSet.rows[row].values!); + } + if (first) { + partial.metadata = resultSet.metadata; + first = false; + } + res.push(partial); + } + if ( + queryMode === QueryMode.PROFILE || + queryMode === 'PROFILE' || + resultSet.stats + ) { + res[res.length - 1].stats = resultSet.stats || { + queryStats: { fields: {} }, + queryPlan: { planNodes: [] }, + }; + } + return res; + } + + private static emptyPartialResultSet( + precommitToken: any, + resumeToken: Uint8Array, + metadata?: protobuf.ResultSetMetadata + ): protobuf.PartialResultSet { + return spannerProto.PartialResultSet.create({ + resumeToken, + precommitToken: precommitToken, + metadata, + }); + } + + private static toPartialResultSet( + precommitToken: any, + rowCount: number + ): protobuf.PartialResultSet { + const stats = { + rowCountExact: rowCount, + rowCount: 'rowCountExact', + }; + return spannerProto.PartialResultSet.create({ + stats, + precommitToken: precommitToken, + }); + } + + private static toResultSet(rowCount: number): protobuf.ResultSet { + const stats = { + rowCountExact: rowCount, + rowCount: 'rowCountExact', + }; + return spannerProto.ResultSet.create({ + stats, + }); + } + + executeBatchDml(call: any, callback: any) { + this.pushRequest(call.request!, call.metadata); + this.simulateExecutionTime(this.executeBatchDml.name) + .then(() => { + if (call.request!.transaction) { + const fullTransactionId = `${call.request!.session}/transactions/${ + call.request!.transaction.id + }`; + if (this.abortedTransactions.has(fullTransactionId)) { + callback( + MockSpanner.createTransactionAbortedError(`${fullTransactionId}`) + ); + return; + } + } + const results: ResultSet[] = []; + let statementStatus = StatusProto.create({ code: gaxGrpc.status.OK }); + for ( + let i = 0; + i < call.request!.statements.length && + statementStatus.code === gaxGrpc.status.OK; + i++ + ) { + const streamErr = this.shiftStreamError(this.executeBatchDml.name, i); + if (streamErr) { + statementStatus = StatusProto.create({ + code: streamErr.code, + message: streamErr.message, + }); + if (streamErr.metadata && streamErr.metadata.get(RETRY_INFO_BIN)) { + const retryInfo = streamErr.metadata.get( + RETRY_INFO_BIN + )[0] as any; + statementStatus.details = [ + AnyProto.create({ + type_url: RETRY_INFO_TYPE, + value: retryInfo, + }), + ]; + } + continue; + } + const statement = call.request!.statements[i]; + const res = this.statementResults.get(statement.sql?.trim()); + if (res) { + switch (res.type) { + case StatementResultType.RESULT_SET: + callback(new Error('Wrong result type for batch DML')); + break; + case StatementResultType.UPDATE_COUNT: { + const resultSet = MockSpanner.toResultSet(res.updateCount); + if (call.request!.transaction?.begin && i === 0) { + const transaction = this._updateTransaction( + call.request!.session, + call.request?.transaction!.begin + ); + if (transaction instanceof Error) { + callback(transaction); + break; + } + resultSet.metadata = spannerProto.ResultSetMetadata.create({ + transaction, + }); + } + results.push(resultSet); + break; + } + case StatementResultType.ERROR: + if ((res.error as any).code) { + const serviceError = res.error as any; + statementStatus = { + code: serviceError.code, + message: serviceError.message, + } as Status; + } else { + statementStatus = { + code: gaxGrpc.status.INTERNAL, + message: res.error.message, + } as Status; + } + break; + default: + callback( + new Error(`Unknown StatementResult type: ${res.type}`) + ); + } + } else { + callback( + new Error(`There is no result registered for ${statement.sql}`) + ); + } + } + callback( + null, + spannerProto.ExecuteBatchDmlResponse.create({ + resultSets: results, + status: statementStatus, + }) + ); + }) + .catch((err) => { + callback(err); + }); + } + + read(call: any, callback: any) { + this.pushRequest(call.request!, call.metadata); + callback(createUnimplementedError('Read is not yet implemented')); + } + + streamingRead(call: any) { + this.pushRequest(call.request!, call.metadata); + + this.simulateExecutionTime(this.streamingRead.name) + .then(() => { + let transactionKey; + if (call.request!.transaction) { + const fullTransactionId = `${call.request!.session}/transactions/${ + call.request!.transaction.id + }`; + transactionKey = fullTransactionId; + if (this.abortedTransactions.has(fullTransactionId)) { + call.sendMetadata(new Metadata()); + call.emit( + 'error', + MockSpanner.createTransactionAbortedError(`${fullTransactionId}`) + ); + call.end(); + return; + } + } + const keySet = JSON.stringify( + call.request!.keySet ?? {}, + Object.keys(call.request!.keySet ?? {}).sort() + ); + const key = `${call.request!.table}|${keySet}`; + const res = this.readRequestResults.get(key); + const session = this.sessions.get(call.request!.session); + if (res) { + if (call.request!.transaction?.begin) { + const txn = this._updateTransaction( + call.request!.session, + call.request!.transaction.begin + ); + if (txn instanceof Error) { + call.sendMetadata(new Metadata()); + call.emit('error', txn); + call.end(); + return; + } + transactionKey = `${call.request!.session}/transactions/${txn.id.toString()}`; + if (res.type === ReadRequestResultType.RESULT_SET) { + call.sendMetadata(new Metadata()); + (res.resultSet as protobuf.ResultSet).metadata!.transaction = txn; + } + } + + const currentSeqNum = + this.transactionSeqNum.get(transactionKey!) || 0; + const nextSeqNum = currentSeqNum + 1; + this.transactionSeqNum.set(transactionKey!, nextSeqNum); + const precommitToken = session?.multiplexed + ? spannerProto.MultiplexedSessionPrecommitToken.create({ + precommitToken: Buffer.from('mock-precommit-token'), + seqNum: nextSeqNum, + }) + : null; + let partialResultSets: any[]; + let resumeIndex: number; + switch (res.type) { + case ReadRequestResultType.RESULT_SET: + if ((res.resultSet as any).precommitToken !== undefined) { + (res.resultSet as protobuf.ResultSet).precommitToken = + precommitToken; + } + if (Array.isArray(res.resultSet)) { + partialResultSets = res.resultSet; + } else { + partialResultSets = MockSpanner.toPartialResultSets( + res.resultSet as any, + 'NORMAL' + ); + } + resumeIndex = + call.request!.resumeToken.length === 0 + ? 0 + : parseInt( + Buffer.from(call.request!.resumeToken).toString(), + 10 + ) + 1; + for ( + let index = resumeIndex; + index < partialResultSets.length; + index++ + ) { + const streamErr = this.shiftStreamError( + this.streamingRead.name, + index + ); + if (streamErr) { + call.sendMetadata(new Metadata()); + call.emit('error', streamErr); + break; + } + call.write(partialResultSets[index]); + } + break; + case ReadRequestResultType.ERROR: + call.sendMetadata(new Metadata()); + call.emit('error', res.error); + break; + default: + call.emit( + 'error', + new Error(`Unknown ReadRequestResult type: ${res.type}`) + ); + } + } else { + call.emit( + 'error', + new Error( + `There is no result registered for ${call.request!.table}` + ) + ); + } + call.end(); + }) + .catch((err) => { + call.sendMetadata(new Metadata()); + call.emit('error', err); + call.end(); + }); + } + + beginTransaction(call: any, callback: any) { + this.pushRequest(call.request!, call.metadata); + this.simulateExecutionTime(this.beginTransaction.name) + .then(() => { + this.mutationOnly = call.request.mutationKey ? true : false; + const res = this._updateTransaction( + call.request!.session, + call.request!.options + ); + if (res instanceof Error) { + callback(res); + } else { + callback(null, res); + } + }) + .catch((err) => { + callback(err); + }); + } + + commit(call: any, callback: any) { + this.pushRequest(call.request!, call.metadata); + this.simulateExecutionTime(this.commit.name) + .then(() => { + const fullTransactionId = `${call.request!.session}/transactions/${ + call.request!.transactionId + }`; + if (this.abortedTransactions.has(fullTransactionId)) { + callback( + MockSpanner.createTransactionAbortedError(`${fullTransactionId}`) + ); + return; + } + const session = this.sessions.get(call.request!.session); + if (session) { + if (call.request!.transactionId) { + const buffer = Buffer.from(call.request!.transactionId as string); + const transactionId = buffer.toString(); + const fullTransactionId = + session.name + '/transactions/' + transactionId; + const transaction = this.transactions.get(fullTransactionId); + if (transaction) { + const transactionKey = `${call.request.session}/transactions/${call.request.transactionId}`; + this.transactionSeqNum.delete(transactionKey); + this.transactions.delete(fullTransactionId); + this.transactionOptions.delete(fullTransactionId); + callback( + null, + spannerProto.CommitResponse.create({ + commitTimestamp: now(), + }) + ); + } else { + callback( + MockSpanner.createTransactionNotFoundError(fullTransactionId) + ); + } + } else if (call.request!.singleUseTransaction) { + callback( + null, + spannerProto.CommitResponse.create({ + commitTimestamp: now(), + }) + ); + } + } else { + callback( + MockSpanner.createSessionNotFoundError(call.request!.session) + ); + } + }) + .catch((err) => { + callback(err); + }); + } + + rollback(call: any, callback: any) { + this.pushRequest(call.request!, call.metadata); + const session = this.sessions.get(call.request!.session); + if (session) { + const buffer = Buffer.from(call.request!.transactionId as string); + const transactionId = buffer.toString(); + const fullTransactionId = session.name + '/transactions/' + transactionId; + const transaction = this.transactions.get(fullTransactionId); + if (transaction) { + const transactionKey = `${call.request.session}/transactions/${call.request.transactionId}`; + this.transactionSeqNum.delete(transactionKey); + this.transactions.delete(fullTransactionId); + this.transactionOptions.delete(fullTransactionId); + callback(null, google.protobuf.Empty.create()); + } else { + callback(MockSpanner.createTransactionNotFoundError(fullTransactionId)); + } + } else { + callback(MockSpanner.createSessionNotFoundError(call.request!.session)); + } + } + + partitionQuery(call: any, callback: any) { + this.pushRequest(call.request!, call.metadata); + this.simulateExecutionTime(this.partitionQuery.name) + .then(() => { + const response = spannerProto.PartitionResponse.create({ + partitions: [{ partitionToken: Buffer.from('mock-token') }], + }); + callback(null, response); + }) + .catch((err) => callback(err)); + } + + partitionRead(call: any, callback: any) { + this.pushRequest(call.request!, call.metadata); + this.simulateExecutionTime(this.partitionRead.name) + .then(() => { + const response = spannerProto.PartitionResponse.create({ + partitions: [{ partitionToken: Buffer.from('mock-token') }], + }); + callback(null, response); + }) + .catch((err) => callback(err)); + } + + batchWrite(call: any) { + this.pushRequest(call.request, call.metadata); + this.simulateExecutionTime(this.batchWrite.name) + .then(() => { + const response = spannerProto.BatchWriteResponse.create({ + commitTimestamp: now(), + }); + call.write(response); + call.end(); + }) + .catch((err) => call.destroy(err)); + } + + private _updateTransaction( + sessionName: string, + options: protobuf.ITransactionOptions | null | undefined + ): protobuf.Transaction | ServiceError { + const session = this.sessions.get(sessionName); + if (!session) { + return MockSpanner.createSessionNotFoundError(sessionName); + } + let counter = this.transactionCounters.get(session.name); + if (!counter) { + counter = 0; + } + const id = ++counter; + this.transactionCounters.set(session.name, counter); + const transactionId = id.toString().padStart(12, '0'); + const fullTransactionId = session.name + '/transactions/' + transactionId; + const readTimestamp = options && options.readOnly ? now() : undefined; + let precommitToken; + if (this.mutationOnly && session.multiplexed && options?.readWrite) { + const currentSeqNum = this.transactionSeqNum.get(fullTransactionId) || 0; + const nextSeqNum = currentSeqNum + 1; + this.transactionSeqNum.set(fullTransactionId, nextSeqNum); + precommitToken = { + precommitToken: Buffer.from('mock-precommit-token'), + seqNum: nextSeqNum, + }; + } + const transaction = spannerProto.Transaction.create({ + id: Buffer.from(transactionId), + readTimestamp, + precommitToken, + }); + this.transactions.set(fullTransactionId, transaction); + this.transactionOptions.set(fullTransactionId, options); + return transaction; + } +} + +export function createMockSpanner( + server: Server, + mockInstance?: MockSpanner +): MockSpanner { + const mock = mockInstance ?? MockSpanner.create(); + server.addService(spannerProtoDescriptor.Spanner.service, { + batchCreateSessions: (mock as any).batchCreateSessions, + createSession: (mock as any).createSession, + getSession: (mock as any).getSession, + listSessions: (mock as any).listSessions, + deleteSession: (mock as any).deleteSession, + executeSql: (mock as any).executeSql, + executeStreamingSql: (mock as any).executeStreamingSql, + executeBatchDml: (mock as any).executeBatchDml, + read: (mock as any).read, + streamingRead: (mock as any).streamingRead, + beginTransaction: (mock as any).beginTransaction, + commit: (mock as any).commit, + rollback: (mock as any).rollback, + partitionQuery: (mock as any).partitionQuery, + partitionRead: (mock as any).partitionRead, + batchWrite: (mock as any).batchWrite, + }); + return mock; +} + +export function createReadRequestResultSet(): protobuf.ResultSet { + const fields = [ + spannerProto.StructType.Field.create({ + name: 'ID', + type: spannerProto.Type.create({ code: spannerProto.TypeCode.STRING }), + }), + spannerProto.StructType.Field.create({ + name: 'VALUE', + type: spannerProto.Type.create({ code: spannerProto.TypeCode.STRING }), + }), + ]; + const metadata = new spannerProto.ResultSetMetadata({ + rowType: new spannerProto.StructType({ fields }), + }); + + return spannerProto.ResultSet.create({ + metadata, + rows: [ + { values: [{ stringValue: 'a' }, { stringValue: 'Alpha' }] }, + { values: [{ stringValue: 'b' }, { stringValue: 'Beta' }] }, + { values: [{ stringValue: 'c' }, { stringValue: 'Gamma' }] }, + ], + }); +} + +export function createSimpleResultSet(): protobuf.ResultSet { + const fields = [ + spannerProto.StructType.Field.create({ + name: 'NUM', + type: spannerProto.Type.create({ code: spannerProto.TypeCode.INT64 }), + }), + spannerProto.StructType.Field.create({ + name: 'NAME', + type: spannerProto.Type.create({ code: spannerProto.TypeCode.STRING }), + }), + ]; + const metadata = new spannerProto.ResultSetMetadata({ + rowType: new spannerProto.StructType({ + fields, + }), + }); + return spannerProto.ResultSet.create({ + metadata, + rows: [ + { values: [{ stringValue: '1' }, { stringValue: 'One' }] }, + { values: [{ stringValue: '2' }, { stringValue: 'Two' }] }, + { values: [{ stringValue: '3' }, { stringValue: 'Three' }] }, + ], + }); +} + +export const NUM_ROWS_LARGE_RESULT_SET = 100; + +export function createLargeResultSet(): protobuf.ResultSet { + const fields = [ + spannerProto.StructType.Field.create({ + name: 'NUM', + type: spannerProto.Type.create({ code: spannerProto.TypeCode.INT64 }), + }), + spannerProto.StructType.Field.create({ + name: 'NAME', + type: spannerProto.Type.create({ code: spannerProto.TypeCode.STRING }), + }), + ]; + const metadata = new spannerProto.ResultSetMetadata({ + rowType: new spannerProto.StructType({ + fields, + }), + }); + const rows: any[] = []; + for (let num = 1; num <= NUM_ROWS_LARGE_RESULT_SET; num++) { + rows.push({ + values: [ + { stringValue: `${num}` }, + { stringValue: generateRandomString(100) }, + ], + }); + } + return spannerProto.ResultSet.create({ + metadata, + rows, + }); +} + +export function createSelect1ResultSet(): protobuf.ResultSet { + const fields = [ + spannerProto.StructType.Field.create({ + name: '', + type: spannerProto.Type.create({ code: spannerProto.TypeCode.INT64 }), + }), + ]; + const metadata = new spannerProto.ResultSetMetadata({ + rowType: new spannerProto.StructType({ + fields, + }), + }); + return spannerProto.ResultSet.create({ + metadata, + rows: [{ values: [{ stringValue: '1' }] }], + }); +} + +export function createSelect1ResultSetWithStats(): protobuf.ResultSet { + const fields = [ + spannerProto.StructType.Field.create({ + name: '', + type: spannerProto.Type.create({ code: spannerProto.TypeCode.INT64 }), + }), + ]; + const metadata = new spannerProto.ResultSetMetadata({ + rowType: new spannerProto.StructType({ + fields, + }), + }); + return spannerProto.ResultSet.create({ + metadata, + rows: [{ values: [{ stringValue: '1' }] }], + stats: spannerProto.ResultSetStats.create({ + queryStats: { + fields: { + elapsed_time: { stringValue: '1ms' }, + }, + }, + }), + }); +} + +export function createDialectResultSet(): protobuf.ResultSet { + const fields = [ + spannerProto.StructType.Field.create({ + name: 'option_value', + type: spannerProto.Type.create({ code: spannerProto.TypeCode.STRING }), + }), + ]; + const metadata = new spannerProto.ResultSetMetadata({ + rowType: new spannerProto.StructType({ + fields, + }), + }); + return spannerProto.ResultSet.create({ + metadata, + rows: [{ values: [{ stringValue: 'GOOGLE_STANDARD_SQL' }] }], + }); +} + +export function createResultSetWithAllDataTypes(): protobuf.ResultSet { + const fields = [ + spannerProto.StructType.Field.create({ + name: 'COLBOOL', + type: spannerProto.Type.create({ code: spannerProto.TypeCode.BOOL }), + }), + spannerProto.StructType.Field.create({ + name: 'COLINT64', + type: spannerProto.Type.create({ code: spannerProto.TypeCode.INT64 }), + }), + spannerProto.StructType.Field.create({ + name: 'COLFLOAT64', + type: spannerProto.Type.create({ code: spannerProto.TypeCode.FLOAT64 }), + }), + spannerProto.StructType.Field.create({ + name: 'COLNUMERIC', + type: spannerProto.Type.create({ code: spannerProto.TypeCode.NUMERIC }), + }), + spannerProto.StructType.Field.create({ + name: 'COLSTRING', + type: spannerProto.Type.create({ code: spannerProto.TypeCode.STRING }), + }), + spannerProto.StructType.Field.create({ + name: 'COLBYTES', + type: spannerProto.Type.create({ code: spannerProto.TypeCode.BYTES }), + }), + spannerProto.StructType.Field.create({ + name: 'COLJSON', + type: spannerProto.Type.create({ code: spannerProto.TypeCode.JSON }), + }), + spannerProto.StructType.Field.create({ + name: 'COLDATE', + type: spannerProto.Type.create({ code: spannerProto.TypeCode.DATE }), + }), + spannerProto.StructType.Field.create({ + name: 'COLTIMESTAMP', + type: spannerProto.Type.create({ code: spannerProto.TypeCode.TIMESTAMP }), + }), + ]; + const metadata = new spannerProto.ResultSetMetadata({ + rowType: new spannerProto.StructType({ + fields, + }), + }); + return spannerProto.ResultSet.create({ + metadata, + rows: [ + { + values: [ + { boolValue: true }, + { stringValue: '1' }, + { numberValue: 3.14 }, + { stringValue: '6.626' }, + { stringValue: 'One' }, + { stringValue: Buffer.from('test').toString('base64') }, + { stringValue: '{"result":true, "count":42}' }, + { stringValue: '2021-05-11' }, + { stringValue: '2021-05-11T16:46:04.872Z' }, + ], + }, + ], + }); +} + +function generateRandomString(length: number) { + let result = ''; + const characters = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + const charactersLength = characters.length; + for (let i = 0; i < length; i++) { + result += characters.charAt(Math.floor(Math.random() * charactersLength)); + } + return result; +} + +export function now(): Timestamp { + const n = Date.now(); + return TimestampProto.create({ + seconds: n / 1000, + nanos: (n % 1000) * 1e6, + }); +} diff --git a/handwritten/spanner-driver/spannerlib-node/test/mockserver/pool.test.ts b/handwritten/spanner-driver/spannerlib-node/test/mockserver/pool.test.ts new file mode 100644 index 000000000000..e5590c9d676e --- /dev/null +++ b/handwritten/spanner-driver/spannerlib-node/test/mockserver/pool.test.ts @@ -0,0 +1,51 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import * as assert from 'assert'; +import { Pool } from '../../src/lib/pool.js'; +import { MockSpanner } from './mockspanner.js'; + +describe('Pool on MockServer', () => { + let mock: MockSpanner; + let connStr: string; + + before(async () => { + mock = MockSpanner.create(); + const port = await mock.listen('127.0.0.1:0'); + connStr = `localhost:${port}/projects/p/instances/i/databases/d?useplaintext=true`; + }); + + after(() => { + mock?.close(); + }); + + it('should create and close Pool successfully on mock server', async () => { + const pool = await Pool.create(connStr); + + assert.ok(pool, 'Pool should be created'); + assert.strictEqual(typeof pool.oid, 'number', 'Pool ID should be assigned'); + assert.strictEqual( + pool.closed, + false, + 'Pool should not be closed initially' + ); + + await pool.close(); + assert.strictEqual(pool.closed, true, 'Pool should be closed'); + + // Test closing again is safe + await pool.close(); + assert.strictEqual(pool.closed, true, 'Pool should remain closed'); + }); +}); diff --git a/handwritten/spanner-driver/spannerlib-node/test/pool.test.ts b/handwritten/spanner-driver/spannerlib-node/test/pool.test.ts new file mode 100644 index 000000000000..009b1f39b877 --- /dev/null +++ b/handwritten/spanner-driver/spannerlib-node/test/pool.test.ts @@ -0,0 +1,77 @@ +// 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 { Pool } from '../src/lib/pool.js'; +import { ffi } from '../src/ffi/utils.js'; +import sinon from 'sinon'; + +describe('Pool', () => { + let stub: sinon.SinonStub; + + beforeEach(() => { + stub = sinon.stub(ffi, 'invokeAsync'); + }); + + afterEach(() => { + stub.restore(); + }); + + it('should create a Pool successfully', async () => { + const connStr = 'projects/test/instances/test/databases/test'; + + // Mock invokeAsync response for CreatePool + stub + .onFirstCall() + .resolves({ objectId: 1, pinnerId: 2, protobufBytes: null }); + + const pool = await Pool.create(connStr); + + assert.ok(pool, 'Pool should be created'); + assert.strictEqual(pool.oid, 1, 'Pool OID should match'); + assert.strictEqual(pool.connStr, connStr, 'Connection string should match'); + + assert.strictEqual( + stub.calledOnce, + true, + 'invokeAsync should be called once' + ); + assert.strictEqual( + stub.firstCall.args[0], + 'CreatePool', + 'First call should be CreatePool' + ); + + // Verify user agent suffix detection + const isESM = typeof require === 'undefined'; + const expectedUA = isESM ? 'node-esm' : 'node-cjs'; + assert.strictEqual( + stub.firstCall.args[1], + expectedUA, + `User agent should be ${expectedUA}` + ); + }); + + it('should throw error on create connection if pool is closed', async () => { + const pool = new Pool( + 'node-esm', + 'projects/test/instances/test/databases/test' + ); + pool.closed = true; + + await assert.rejects(async () => { + await pool.createConnection(); + }, /Pool is already closed/); + }); +}); diff --git a/handwritten/spanner-driver/spannerlib-node/test/rows.test.ts b/handwritten/spanner-driver/spannerlib-node/test/rows.test.ts new file mode 100644 index 000000000000..52b597dcc4cb --- /dev/null +++ b/handwritten/spanner-driver/spannerlib-node/test/rows.test.ts @@ -0,0 +1,468 @@ +// 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 { Rows } from '../src/lib/rows.js'; +import { Connection } from '../src/lib/connection.js'; +import { Pool } from '../src/lib/pool.js'; +import { ffi } from '../src/ffi/utils.js'; +import sinon from 'sinon'; +import pkg from '@google-cloud/spanner/build/protos/protos.js'; +const { google } = pkg; +const ListValue = google.protobuf.ListValue; +const Value = google.protobuf.Value; + +describe('Rows', () => { + let stub: sinon.SinonStub; + + beforeEach(() => { + stub = sinon.stub(ffi, 'invokeAsync'); + }); + + afterEach(() => { + stub.restore(); + }); + + interface MockListValue { + values: Array<{ stringValue: string }>; + } + + it('should fetch next row successfully', async () => { + const pool = new Pool( + 'node-esm', + 'projects/test/instances/test/databases/test' + ); + pool.oid = 1; + const connection = new Connection(); + connection.pool = pool; + connection.oid = 2; + + const rows = new Rows(connection, 3); + + // Create a dummy ListValue + const listValue = ListValue.create({ + values: [Value.create({ stringValue: '1' })], + }); + const buffer = ListValue.encode(listValue).finish() as Buffer; + + stub + .onFirstCall() + .resolves({ objectId: 0, pinnerId: 0, protobufBytes: buffer }); + + const row = (await rows.next()) as unknown as MockListValue; + + assert.ok(row, 'Row should be returned'); + assert.strictEqual(row.values.length, 1, 'Row should have 1 value'); + assert.strictEqual(row.values[0].stringValue, '1', 'Value should be "1"'); + + assert.strictEqual( + stub.calledOnce, + true, + 'invokeAsync should be called once' + ); + assert.strictEqual( + stub.firstCall.args[0], + 'Next', + 'First call should be Next' + ); + }); + + it('should return null when no more rows', async () => { + const pool = new Pool( + 'node-esm', + 'projects/test/instances/test/databases/test' + ); + pool.oid = 1; + const connection = new Connection(); + connection.pool = pool; + connection.oid = 2; + + const rows = new Rows(connection, 3); + + stub + .onFirstCall() + .resolves({ objectId: 0, pinnerId: 0, protobufBytes: null }); + + const row = await rows.next(); + + assert.strictEqual(row, null, 'Row should be null'); + }); + + it('should retrieve metadata successfully', async () => { + const pool = new Pool( + 'node-esm', + 'projects/test/instances/test/databases/test' + ); + pool.oid = 1; + const connection = new Connection(); + connection.pool = pool; + connection.oid = 2; + + const rows = new Rows(connection, 3); + + const metadataProto = google.spanner.v1.ResultSetMetadata.create({ + rowType: { + fields: [{ name: 'col1', type: { code: 'INT64' } }], + }, + }); + const buffer = google.spanner.v1.ResultSetMetadata.encode( + metadataProto + ).finish() as Buffer; + + stub + .onFirstCall() + .resolves({ objectId: 0, pinnerId: 0, protobufBytes: buffer }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const metadata: any = await rows.metadata(); + assert.ok(metadata, 'Metadata should be returned'); + assert.ok(metadata.rowType, 'RowType should exist'); + assert.strictEqual(metadata.rowType.fields[0].name, 'col1'); + assert.strictEqual(stub.firstCall.args[0], 'Metadata'); + }); + + it('should retrieve stats successfully', async () => { + const pool = new Pool( + 'node-esm', + 'projects/test/instances/test/databases/test' + ); + pool.oid = 1; + const connection = new Connection(); + connection.pool = pool; + connection.oid = 2; + + const rows = new Rows(connection, 3); + + const statsProto = google.spanner.v1.ResultSetStats.create({ + rowCountExact: 5, + }); + const buffer = google.spanner.v1.ResultSetStats.encode( + statsProto + ).finish() as Buffer; + + stub + .onFirstCall() + .resolves({ objectId: 0, pinnerId: 0, protobufBytes: buffer }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const stats: any = await rows.resultSetStats(); + assert.ok(stats, 'Stats should be returned'); + assert.strictEqual(Number(stats.rowCountExact), 5); + assert.strictEqual(stub.firstCall.args[0], 'ResultSetStats'); + }); + + it('should advance to next result set successfully', async () => { + const pool = new Pool( + 'node-esm', + 'projects/test/instances/test/databases/test' + ); + pool.oid = 1; + const connection = new Connection(); + connection.pool = pool; + connection.oid = 2; + + const rows = new Rows(connection, 3); + + const metadataProto = google.spanner.v1.ResultSetMetadata.create({ + rowType: { + fields: [{ name: 'col2', type: { code: 'STRING' } }], + }, + }); + const buffer = google.spanner.v1.ResultSetMetadata.encode( + metadataProto + ).finish() as Buffer; + + stub + .onFirstCall() + .resolves({ objectId: 0, pinnerId: 0, protobufBytes: buffer }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const metadata: any = await rows.nextResultSet(); + assert.ok(metadata, 'Metadata should be returned'); + assert.strictEqual(metadata.rowType.fields[0].name, 'col2'); + assert.strictEqual(stub.firstCall.args[0], 'NextResultSet'); + }); + + it('should calculate updateCount correctly for exact and lower bound values', async () => { + const pool = new Pool( + 'node-esm', + 'projects/test/instances/test/databases/test' + ); + pool.oid = 1; + const connection = new Connection(); + connection.pool = pool; + connection.oid = 2; + + const rows = new Rows(connection, 3); + + // Exact count test + const exactStats = google.spanner.v1.ResultSetStats.create({ + rowCountExact: 12, + }); + const exactBuffer = google.spanner.v1.ResultSetStats.encode( + exactStats + ).finish() as Buffer; + + stub + .onFirstCall() + .resolves({ objectId: 0, pinnerId: 0, protobufBytes: exactBuffer }); + const countExact = await rows.updateCount(); + assert.strictEqual(countExact, 12); + + stub.restore(); + stub = sinon.stub(ffi, 'invokeAsync'); + + // Lower bound count test + const rowsLower = new Rows(connection, 4); + const lowerBoundStats = google.spanner.v1.ResultSetStats.create({ + rowCountLowerBound: 42, + }); + const lowerBoundBuffer = google.spanner.v1.ResultSetStats.encode( + lowerBoundStats + ).finish() as Buffer; + + stub + .onFirstCall() + .resolves({ objectId: 0, pinnerId: 0, protobufBytes: lowerBoundBuffer }); + const countLower = await rowsLower.updateCount(); + assert.strictEqual(countLower, 42); + }); + + it('should throw error on operations if connection is closed', async () => { + const pool = new Pool( + 'node-esm', + 'projects/test/instances/test/databases/test' + ); + pool.oid = 1; + const connection = new Connection(); + connection.pool = pool; + connection.oid = 2; + + const rows = new Rows(connection, 3); + connection.closed = true; + + await assert.rejects(async () => { + await rows.next(); + }, /Connection is closed or invalid/); + + await assert.rejects(async () => { + await rows.metadata(); + }, /Connection is closed or invalid/); + + await assert.rejects(async () => { + await rows.resultSetStats(); + }, /Connection is closed or invalid/); + + await assert.rejects(async () => { + await rows.nextResultSet(); + }, /Connection is closed or invalid/); + }); + + it('should throw error on operations if rows are closed', async () => { + const pool = new Pool( + 'node-esm', + 'projects/test/instances/test/databases/test' + ); + pool.oid = 1; + const connection = new Connection(); + connection.pool = pool; + connection.oid = 2; + + const rows = new Rows(connection, 3); + rows.closed = true; + + await assert.rejects(async () => { + await rows.next(); + }, /Rows are already closed/); + + await assert.rejects(async () => { + await rows.metadata(); + }, /Rows are already closed/); + + await assert.rejects(async () => { + await rows.resultSetStats(); + }, /Rows are already closed/); + + await assert.rejects(async () => { + await rows.nextResultSet(); + }, /Rows are already closed/); + }); + + it('should cache metadata and return it without repeating FFI calls', async () => { + const pool = new Pool( + 'node-esm', + 'projects/test/instances/test/databases/test' + ); + pool.oid = 1; + const connection = new Connection(); + connection.pool = pool; + connection.oid = 2; + + const rows = new Rows(connection, 3); + + // Mock metadata response + const mockMeta = google.spanner.v1.ResultSetMetadata.create({ + rowType: new google.spanner.v1.StructType({ + fields: [ + { name: 'col1', type: { code: google.spanner.v1.TypeCode.STRING } }, + ], + }), + }); + const metaBuffer = google.spanner.v1.ResultSetMetadata.encode( + mockMeta + ).finish() as Buffer; + + stub + .onFirstCall() + .resolves({ objectId: 0, pinnerId: 0, protobufBytes: metaBuffer }); + + // Call metadata twice + const meta1 = await rows.metadata(); + const meta2 = await rows.metadata(); + + assert.ok(meta1); + assert.strictEqual(meta1, meta2); + assert.strictEqual( + stub.callCount, + 1, + 'FFI Metadata should be invoked exactly once' + ); + }); + + it('should cache resultSetStats and return it without repeating FFI calls', async () => { + const pool = new Pool( + 'node-esm', + 'projects/test/instances/test/databases/test' + ); + pool.oid = 1; + const connection = new Connection(); + connection.pool = pool; + connection.oid = 2; + + const rows = new Rows(connection, 3); + + // Mock stats response + const mockStats = google.spanner.v1.ResultSetStats.create({ + rowCountExact: 100, + }); + const statsBuffer = google.spanner.v1.ResultSetStats.encode( + mockStats + ).finish() as Buffer; + + stub + .onFirstCall() + .resolves({ objectId: 0, pinnerId: 0, protobufBytes: statsBuffer }); + + // Call stats twice + const stats1 = await rows.resultSetStats(); + const stats2 = await rows.resultSetStats(); + + assert.ok(stats1); + assert.strictEqual(stats1, stats2); + assert.strictEqual( + stub.callCount, + 1, + 'FFI ResultSetStats should be invoked exactly once' + ); + }); + + it('should clear cached metadata and stats when calling nextResultSet()', async () => { + const pool = new Pool( + 'node-esm', + 'projects/test/instances/test/databases/test' + ); + pool.oid = 1; + const connection = new Connection(); + connection.pool = pool; + connection.oid = 2; + + const rows = new Rows(connection, 3); + + // Mock metadata response + const mockMeta = google.spanner.v1.ResultSetMetadata.create({ + rowType: new google.spanner.v1.StructType({ + fields: [ + { name: 'col1', type: { code: google.spanner.v1.TypeCode.STRING } }, + ], + }), + }); + const metaBuffer = google.spanner.v1.ResultSetMetadata.encode( + mockMeta + ).finish() as Buffer; + + // Mock stats response + const mockStats = google.spanner.v1.ResultSetStats.create({ + rowCountExact: 100, + }); + const statsBuffer = google.spanner.v1.ResultSetStats.encode( + mockStats + ).finish() as Buffer; + + const nextMeta = google.spanner.v1.ResultSetMetadata.create({ + rowType: new google.spanner.v1.StructType({ + fields: [ + { name: 'col2', type: { code: google.spanner.v1.TypeCode.INT64 } }, + ], + }), + }); + const nextMetaBuffer = google.spanner.v1.ResultSetMetadata.encode( + nextMeta + ).finish() as Buffer; + + const nextStats = google.spanner.v1.ResultSetStats.create({ + rowCountExact: 50, + }); + const nextStatsBuffer = google.spanner.v1.ResultSetStats.encode( + nextStats + ).finish() as Buffer; + + stub + .onCall(0) + .resolves({ objectId: 0, pinnerId: 0, protobufBytes: metaBuffer }); + stub + .onCall(1) + .resolves({ objectId: 0, pinnerId: 0, protobufBytes: statsBuffer }); + stub + .onCall(2) + .resolves({ objectId: 0, pinnerId: 0, protobufBytes: nextMetaBuffer }); + stub + .onCall(3) + .resolves({ objectId: 0, pinnerId: 0, protobufBytes: nextStatsBuffer }); + + // Populate initial caches + const meta1 = await rows.metadata(); + const stats1 = await rows.resultSetStats(); + assert.ok(meta1); + assert.ok(stats1); + + // Call nextResultSet() + const nextResultSetMetadata = await rows.nextResultSet(); + assert.ok(nextResultSetMetadata); + + // Verify metadata was updated and matches nextMeta + assert.strictEqual( + nextResultSetMetadata.rowType?.fields?.[0]?.name, + 'col2' + ); + + // Verify stats were cleared (calling stats again invokes the stub a 4th time) + const stats2 = await rows.resultSetStats(); + assert.ok(stats2); + assert.strictEqual(stats2.rowCountExact?.toString(), '50'); + + // Total invoke count should be 4 (Metadata, ResultSetStats, NextResultSet, ResultSetStats) + // (Notice we did not invoke Metadata again because nextResultSet returned and cached the new metadata) + assert.strictEqual(stub.callCount, 4); + }); +}); diff --git a/handwritten/spanner-driver/spannerlib-node/tsconfig.cjs.json b/handwritten/spanner-driver/spannerlib-node/tsconfig.cjs.json new file mode 100644 index 000000000000..9126f3f5e766 --- /dev/null +++ b/handwritten/spanner-driver/spannerlib-node/tsconfig.cjs.json @@ -0,0 +1,11 @@ +{ + "extends": "gts/tsconfig-google.json", + "compilerOptions": { + "target": "es2023", + "outDir": "build/cjs", + "esModuleInterop": true, + "types": ["node", "mocha"] + }, + "include": ["src/**/*", "test/**/*"], + "exclude": ["node_modules", "build"] +} diff --git a/handwritten/spanner-driver/spannerlib-node/tsconfig.json b/handwritten/spanner-driver/spannerlib-node/tsconfig.json new file mode 100644 index 000000000000..b3446bf4c68e --- /dev/null +++ b/handwritten/spanner-driver/spannerlib-node/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "gts/tsconfig-google.json", + "compilerOptions": { + "target": "es2023", + "module": "nodenext", + "moduleResolution": "nodenext", + "outDir": "build/esm", + "esModuleInterop": true, + "types": ["node", "mocha"] + }, + "include": ["src/**/*", "test/**/*"], + "exclude": ["node_modules", "build"] +}