diff --git a/.github/workflows/samples.yaml b/.github/workflows/samples.yaml
index aba4bf4c9..26cef86da 100644
--- a/.github/workflows/samples.yaml
+++ b/.github/workflows/samples.yaml
@@ -55,3 +55,16 @@ jobs:
run: |
pip install -r requirements.txt
python run_sample.py
+ nodejs-samples:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 20
+ - run: node --version
+ - name: Run Knex Sample tests
+ working-directory: ./samples/nodejs/knex
+ run: |
+ npm install
+ npm start
diff --git a/README.md b/README.md
index 932a77c24..79805c4e8 100644
--- a/README.md
+++ b/README.md
@@ -39,6 +39,8 @@ PGAdapter can be used with the following frameworks and tools:
1. `Ruby ActiveRecord`: Version 7.x has _experimental support_ and with limitations. Please read the
instructions in [PGAdapter - Ruby ActiveRecord Connection Options](docs/ruby-activerecord.md)
carefully for how to set up ActiveRecord to work with PGAdapter.
+1. `Knex.js` query builder can be used with PGAdapter. See [Knex.js sample application](samples/nodejs/knex)
+ for a sample application.
## FAQ
See [Frequently Asked Questions](docs/faq.md) for answers to frequently asked questions.
diff --git a/samples/nodejs/knex/README.md b/samples/nodejs/knex/README.md
new file mode 100644
index 000000000..2ac1d9434
--- /dev/null
+++ b/samples/nodejs/knex/README.md
@@ -0,0 +1,17 @@
+
+
+# PGAdapter Spanner and Knex.js
+
+PGAdapter has experimental support for [Knex.js](https://knexjs.org/) with the standard Node.js `pg`
+driver. This sample application shows how to connect to PGAdapter with Knex, and how to execute
+queries and transactions on Cloud Spanner.
+
+The sample uses the Cloud Spanner emulator. You can run the sample on the emulator with this
+command:
+
+```shell
+npm start
+```
+
+PGAdapter and the emulator are started in a Docker test container by the sample application.
+Docker is therefore required to be installed on your system to run this sample.
diff --git a/samples/nodejs/knex/package.json b/samples/nodejs/knex/package.json
new file mode 100644
index 000000000..e46d63199
--- /dev/null
+++ b/samples/nodejs/knex/package.json
@@ -0,0 +1,20 @@
+{
+ "name": "knex-sample",
+ "version": "0.0.1",
+ "description": "Knex Query Builder Sample",
+ "type": "commonjs",
+ "devDependencies": {
+ "@types/node": "^20.1.4",
+ "ts-node": "10.9.1",
+ "typescript": "5.2.2"
+ },
+ "dependencies": {
+ "pg": "^8.9.0",
+ "knex": "^3.0.1",
+ "testcontainers": "^10.7.1",
+ "yargs": "^17.5.1"
+ },
+ "scripts": {
+ "start": "ts-node src/index.ts"
+ }
+}
diff --git a/samples/nodejs/knex/src/index.ts b/samples/nodejs/knex/src/index.ts
new file mode 100644
index 000000000..f3bcb1f04
--- /dev/null
+++ b/samples/nodejs/knex/src/index.ts
@@ -0,0 +1,175 @@
+// Copyright 2024 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 {Knex} from "knex";
+import {createDataModel, startPGAdapter} from './init';
+import {Album, Concert, Singer, TicketSale, Track, Venue} from './model';
+import {randomInt, randomUUID} from "crypto";
+import {randomAlbumTitle, randomFirstName, randomLastName, randomTrackTitle} from "./random";
+
+async function main() {
+ // Start PGAdapter and the Spanner emulator in a Docker container.
+ // Using a TestContainer to run PGAdapter is OK in development and test, but for production, it is
+ // recommended to run PGAdapter as a side-car container.
+ // See https://github.com/GoogleCloudPlatform/pgadapter/tree/postgresql-dialect/samples/cloud-run/nodejs
+ // for a sample.
+ const pgAdapter = await startPGAdapter();
+
+ // Connect to PGAdapter with the standard PostgreSQL driver.
+ const knex = require('knex')({
+ client: 'pg',
+ connection: {
+ host: 'localhost',
+ port: pgAdapter.getMappedPort(5432),
+ database: 'knex-sample',
+ ssl: false,
+ jsonbSupport: true,
+ }
+ }) as Knex;
+
+ // Create the sample tables (if they do not exist), and delete any existing test data before
+ // running the sample.
+ await createDataModel(knex);
+ await deleteAllData(knex);
+
+ // Create and then print some random data.
+ await createRandomSingersAndAlbums(knex, 20);
+ await printSingersAlbums(knex);
+
+ // Create a Venue, Concert and TicketSale row.
+ // The ticket_sales table uses an auto-generated primary key that is generated by a bit-reversed
+ // sequence. The value can be returned to the application using a 'returning' clause.
+ await createVenuesAndConcerts(knex);
+
+ // Close the knex connection pool and shut down PGAdapter.
+ await knex.destroy();
+ await pgAdapter.stop();
+}
+
+async function createRandomSingersAndAlbums(knex: Knex, numSingers: number) {
+ console.log("Creating random singers and albums...");
+ const singers: Singer[] = new Array(numSingers);
+ const albums: Album[] = [];
+ const tracks: Track[] = [];
+
+ await knex.transaction(async tx => {
+
+ // Generate some random singers.
+ for (let i=0; i('singers');
+ process.stdout.write('.');
+ }
+ for (let i=0; i('albums');
+ process.stdout.write('.');
+ }
+ for (let i=0; i('tracks');
+ process.stdout.write('.');
+ }
+ console.log('');
+ });
+ console.log(`Finished creating ${singers.length} singers, ${albums.length} albums, and ${tracks.length} tracks.`);
+}
+
+async function printSingersAlbums(knex: Knex) {
+ const singers = await knex.select('*').from('singers').orderBy('last_name');
+ for (const singer of singers) {
+ console.log(`Singer ${singer.full_name} has albums:`);
+ const albums = await knex.select('*')
+ .from('albums')
+ .where('singer_id', singer.id)
+ .orderBy('title');
+ for (const album of albums) {
+ console.log(`\t${album.title}`);
+ }
+ }
+}
+
+async function createVenuesAndConcerts(knex: Knex) {
+ console.log("Creating venues and concerts...");
+ await knex.transaction(async tx => {
+ const singer = await tx.select('*').from('singers').first();
+ const venue = {
+ id: randomUUID(),
+ name: 'Avenue Park',
+ description: '{"Capacity": 5000, "Location": "New York", "Country": "US"}'
+ } as Venue;
+ await tx.insert(venue).into('venues');
+ const concert = {
+ id: randomUUID(),
+ name: 'Avenue Park Open',
+ singer_id: singer!.id,
+ venue_id: venue.id,
+ start_time: new Date('2023-02-01T20:00:00-05:00'),
+ end_time: new Date('2023-02-02T02:00:00-05:00'),
+ } as Concert;
+ await tx.insert(concert).into('concerts');
+
+ // TicketSale uses an auto-generated primary key, so we don't need to supply a value for it.
+ // The primary key value is generated by a bit-reversed sequence.
+ const ticketSale = {
+ concert_id: concert.id,
+ customer_name: `${randomFirstName()} ${randomLastName()}`,
+ price: Math.random() * 1000,
+ seats: ['A19', 'A20', 'A21'],
+ } as TicketSale;
+ // The generated ID can be returned.
+ const rows = await tx.insert(ticketSale).into('ticket_sales').returning('id');
+ ticketSale.id = rows[0].id;
+ });
+ console.log("Finished creating venues and concerts");
+}
+
+async function deleteAllData(knex: Knex) {
+ console.log("Deleting all existing test data...");
+ await knex('ticket_sales').delete();
+ await knex('concerts').delete();
+ await knex('venues').delete();
+ await knex