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('tracks').delete(); + await knex('albums').delete(); + await knex('singers').delete(); + console.log("Finished deleting all existing test data"); +} + +(async () => { + await main(); +})().catch(e => { + console.error(e); + process.exit(1); +}); diff --git a/samples/nodejs/knex/src/init.ts b/samples/nodejs/knex/src/init.ts new file mode 100644 index 000000000..3df40922a --- /dev/null +++ b/samples/nodejs/knex/src/init.ts @@ -0,0 +1,128 @@ +// 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 {GenericContainer, PullPolicy, StartedTestContainer, TestContainer} from "testcontainers"; +import {Knex} from "knex"; + +/** + * Creates the data model that is needed for this sample application. + * + * The Cloud Spanner PostgreSQL dialect does not support all system tables (pg_catalog tables) that + * are present in open-source PostgreSQL databases. Those tables are used by Sequelize migrations. + * Migrations are therefore not supported. + */ +export async function createDataModel(knex: Knex) { + console.log("Checking whether tables already exists"); + const result: any = await knex.raw(` + SELECT COUNT(1) AS c + FROM information_schema.tables + WHERE table_schema='public' + AND table_name IN ('singers', 'albums', 'tracks', 'venues', 'concerts', 'ticket_sales')`); + if (result.rows[0].c == '6') { + console.log("Sample data model already exists, not creating any new tables"); + return; + } + console.log("Creating tables..."); + // Create the data model. + await knex.raw( + ` + create table if not exists singers ( + id varchar not null primary key, + first_name varchar, + last_name varchar not null, + full_name varchar(300) generated always as ( + CASE WHEN first_name IS NULL THEN last_name + WHEN last_name IS NULL THEN first_name + ELSE first_name || ' ' || last_name + END) stored, + active boolean, + created_at timestamptz, + updated_at timestamptz + ); + + create table if not exists albums ( + id varchar not null primary key, + title varchar not null, + marketing_budget numeric, + release_date date, + cover_picture bytea, + singer_id varchar not null, + created_at timestamptz, + updated_at timestamptz, + constraint fk_albums_singers foreign key (singer_id) references singers (id) + ); + + create table if not exists tracks ( + id varchar not null, + track_number bigint not null, + title varchar not null, + sample_rate float8 not null, + created_at timestamptz, + updated_at timestamptz, + primary key (id, track_number) + ) interleave in parent albums on delete cascade; + + create table if not exists venues ( + id varchar not null primary key, + name varchar not null, + description varchar not null, + created_at timestamptz, + updated_at timestamptz + ); + + create table if not exists concerts ( + id varchar not null primary key, + venue_id varchar not null, + singer_id varchar not null, + name varchar not null, + start_time timestamptz not null, + end_time timestamptz not null, + created_at timestamptz, + updated_at timestamptz, + constraint fk_concerts_venues foreign key (venue_id) references venues (id), + constraint fk_concerts_singers foreign key (singer_id) references singers (id), + constraint chk_end_time_after_start_time check (end_time > start_time) + ); + + -- Create a bit-reversed sequence that will be used to generate identifiers for the ticket_sales table. + -- See also https://cloud.google.com/spanner/docs/reference/postgresql/data-definition-language#create_sequence + -- Note that the 'bit_reversed_positive' keyword is required for Spanner, + -- and is automatically skipped for open-source PostgreSQL. + create sequence if not exists ticket_sale_seq + bit_reversed_positive + skip range 1 1000 + start counter with 50000; + + create table if not exists ticket_sales ( + id bigint not null primary key default nextval('ticket_sale_seq'), + concert_id varchar not null, + customer_name varchar not null, + price decimal not null, + seats text[], + created_at timestamptz, + updated_at timestamptz, + constraint fk_ticket_sales_concerts foreign key (concert_id) references concerts (id) + ); + `); + console.log("Finished creating tables"); +} + +export async function startPGAdapter(): Promise { + console.log("Pulling PGAdapter and Spanner emulator"); + const container: TestContainer = new GenericContainer("gcr.io/cloud-spanner-pg-adapter/pgadapter-emulator") + .withPullPolicy(PullPolicy.alwaysPull()) + .withExposedPorts(5432); + console.log("Starting PGAdapter and Spanner emulator"); + return await container.start(); +} diff --git a/samples/nodejs/knex/src/model.ts b/samples/nodejs/knex/src/model.ts new file mode 100644 index 000000000..e41c99d29 --- /dev/null +++ b/samples/nodejs/knex/src/model.ts @@ -0,0 +1,74 @@ +// 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 * as buffer from "buffer"; + +export interface Singer { + id: string; + first_name: string; + last_name: string; + full_name: string; + active: boolean; + created_at: Date; + updated_at: Date; +} + +export interface Album { + id: string; + title: string; + marketing_budget: number; + release_date: Date; + cover_picture: Buffer; + singer_id: string; + created_at: Date; + updated_at: Date; +} + +export interface Track { + id: string; // Table "tracks" is interleaved in "albums", so this "id" is equal to "albums"."id". + track_number: number; + title: string; + sample_rate: number; + created_at: Date; + updated_at: Date; +} + +export interface Venue { + id: string; + name: string; + description: string; + created_at: Date; + updated_at: Date; +} + +export interface Concert { + id: string; + venue_id: string; + singer_id: string; + name: string; + start_time: Date; + end_time: Date; + created_at: Date; + updated_at: Date; +} + +export interface TicketSale { + id: number; + concert_id: string; + customer_name: string; + price: number; + seats: string[]; + created_at: Date; + updated_at: Date; +} diff --git a/samples/nodejs/knex/src/random.ts b/samples/nodejs/knex/src/random.ts new file mode 100644 index 000000000..501b2fcd5 --- /dev/null +++ b/samples/nodejs/knex/src/random.ts @@ -0,0 +1,134 @@ +// 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. + + +export function randomInt(min: number, max: number): number { + return Math.floor(Math.random() * (max - min + 1) + min); +} + +export function randomFirstName(): string { + return randomArrayElement(first_names); +} + +export function randomLastName(): string { + return randomArrayElement(last_names); +} + +export function randomAlbumTitle(): string { + return `${randomArrayElement(adjectives)} ${randomArrayElement(nouns)}`; +} + +export function randomTrackTitle(): string { + return `${randomArrayElement(adverbs)} ${randomArrayElement(verbs)}`; +} + +function randomArrayElement(array: Array): string { + return array[Math.floor(Math.random() * array.length)]; +} + +const first_names: string[] = [ + "Saffron", "Eleanor", "Ann", "Salma", "Kiera", "Mariam", "Georgie", "Eden", "Carmen", "Darcie", + "Antony", "Benjamin", "Donald", "Keaton", "Jared", "Simon", "Tanya", "Julian", "Eugene", "Laurence"]; + +const last_names: string[] = [ + "Terry", "Ford", "Mills", "Connolly", "Newton", "Rodgers", "Austin", "Floyd", "Doherty", "Nguyen", + "Chavez", "Crossley", "Silva", "George", "Baldwin", "Burns", "Russell", "Ramirez", "Hunter", "Fuller"]; + +export const adjectives: string[] = [ + "ultra", + "happy", + "emotional", + "filthy", + "charming", + "alleged", + "talented", + "exotic", + "lamentable", + "lewd", + "old-fashioned", + "savory", + "delicate", + "willing", + "habitual", + "upset", + "gainful", + "nonchalant", + "kind", + "unruly"]; + +export const nouns: string[] = [ + "improvement", + "control", + "tennis", + "gene", + "department", + "person", + "awareness", + "health", + "development", + "platform", + "garbage", + "suggestion", + "agreement", + "knowledge", + "introduction", + "recommendation", + "driver", + "elevator", + "industry", + "extent"]; + +export const verbs: string[] = [ + "instruct", + "rescue", + "disappear", + "import", + "inhibit", + "accommodate", + "dress", + "describe", + "mind", + "strip", + "crawl", + "lower", + "influence", + "alter", + "prove", + "race", + "label", + "exhaust", + "reach", + "remove"]; + +export const adverbs: string[] = [ + "cautiously", + "offensively", + "immediately", + "soon", + "judgementally", + "actually", + "honestly", + "slightly", + "limply", + "rigidly", + "fast", + "normally", + "unnecessarily", + "wildly", + "unimpressively", + "helplessly", + "rightfully", + "kiddingly", + "early", + "queasily"]; diff --git a/src/test/java/com/google/cloud/spanner/pgadapter/nodejs/KnexMockServerTest.java b/src/test/java/com/google/cloud/spanner/pgadapter/nodejs/KnexMockServerTest.java new file mode 100644 index 000000000..763aab782 --- /dev/null +++ b/src/test/java/com/google/cloud/spanner/pgadapter/nodejs/KnexMockServerTest.java @@ -0,0 +1,135 @@ +// Copyright 2023 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. + +package com.google.cloud.spanner.pgadapter.nodejs; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import com.google.cloud.spanner.MockSpannerServiceImpl.StatementResult; +import com.google.cloud.spanner.Statement; +import com.google.cloud.spanner.pgadapter.AbstractMockServerTest; +import com.google.cloud.spanner.pgadapter.metadata.OptionsMetadata; +import com.google.common.collect.ImmutableList; +import com.google.protobuf.ListValue; +import com.google.protobuf.Value; +import com.google.spanner.v1.ExecuteSqlRequest; +import com.google.spanner.v1.ExecuteSqlRequest.QueryMode; +import com.google.spanner.v1.ResultSet; +import com.google.spanner.v1.ResultSetMetadata; +import com.google.spanner.v1.TypeCode; +import java.io.IOException; +import java.util.List; +import java.util.stream.Collectors; +import org.junit.BeforeClass; +import org.junit.Test; +import org.junit.experimental.categories.Category; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import org.junit.runners.Parameterized.Parameter; +import org.junit.runners.Parameterized.Parameters; + +@Category(NodeJSTest.class) +@RunWith(Parameterized.class) +public class KnexMockServerTest extends AbstractMockServerTest { + @Parameter public boolean useDomainSocket; + + @Parameters(name = "useDomainSocket = {0}") + public static Object[] data() { + OptionsMetadata options = new OptionsMetadata(new String[] {"-p p", "-i i"}); + return options.isDomainSocketEnabled() ? new Object[] {true, false} : new Object[] {false}; + } + + @BeforeClass + public static void installDependencies() throws IOException, InterruptedException { + NodeJSTest.installDependencies("knex-tests"); + } + + private String getHost() { + if (useDomainSocket) { + return "/tmp"; + } + return "localhost"; + } + + @Test + public void testSelect1() throws Exception { + String sql = "select * from (select 1)"; + mockSpanner.putStatementResult(StatementResult.query(Statement.of(sql), SELECT1_RESULTSET)); + + String output = runTest("testSelect1", getHost(), pgServer.getLocalPort()); + + assertEquals("SELECT 1 returned: 1\n", output); + + List executeSqlRequests = + mockSpanner.getRequestsOfType(ExecuteSqlRequest.class).stream() + .filter(request -> request.getSql().equals(sql)) + .collect(Collectors.toList()); + assertEquals(1, executeSqlRequests.size()); + ExecuteSqlRequest request = executeSqlRequests.get(0); + assertTrue(request.getTransaction().hasSingleUse()); + assertTrue(request.getTransaction().getSingleUse().hasReadOnly()); + } + + @Test + public void testSelectUser() throws Exception { + String sql = "select * from \"users\" where \"id\" = $1 limit $2"; + ResultSetMetadata metadata = + createMetadata( + ImmutableList.of(TypeCode.INT64, TypeCode.STRING), ImmutableList.of("id", "name")) + .toBuilder() + .setUndeclaredParameters( + createParameterTypesMetadata(ImmutableList.of(TypeCode.INT64, TypeCode.INT64)) + .getUndeclaredParameters()) + .build(); + mockSpanner.putStatementResult( + StatementResult.query( + Statement.of(sql), ResultSet.newBuilder().setMetadata(metadata).build())); + mockSpanner.putStatementResult( + StatementResult.query( + Statement.newBuilder(sql).bind("p1").to(1L).bind("p2").to(1L).build(), + ResultSet.newBuilder() + .setMetadata(metadata) + .addRows( + ListValue.newBuilder() + .addValues(Value.newBuilder().setStringValue("1").build()) + .addValues(Value.newBuilder().setStringValue("User 1")) + .build()) + .build())); + + String output = runTest("testSelectUser", getHost(), pgServer.getLocalPort()); + + assertEquals("{ id: '1', name: 'User 1' }\n", output); + + List executeSqlRequests = + mockSpanner.getRequestsOfType(ExecuteSqlRequest.class).stream() + .filter(request -> request.getSql().equals(sql)) + .collect(Collectors.toList()); + // Knex uses the extended protocol and describes the statement first. + assertEquals(2, executeSqlRequests.size()); + ExecuteSqlRequest planRequest = executeSqlRequests.get(0); + assertEquals(QueryMode.PLAN, planRequest.getQueryMode()); + assertTrue(planRequest.getTransaction().hasSingleUse()); + assertTrue(planRequest.getTransaction().getSingleUse().hasReadOnly()); + ExecuteSqlRequest executeRequest = executeSqlRequests.get(1); + assertEquals(QueryMode.NORMAL, executeRequest.getQueryMode()); + assertTrue(executeRequest.getTransaction().hasSingleUse()); + assertTrue(executeRequest.getTransaction().getSingleUse().hasReadOnly()); + } + + static String runTest(String testName, String host, int port) + throws IOException, InterruptedException { + return NodeJSTest.runTest("knex-tests", testName, host, port, "db"); + } +} diff --git a/src/test/nodejs/knex-tests/package.json b/src/test/nodejs/knex-tests/package.json new file mode 100644 index 000000000..0441fcc0f --- /dev/null +++ b/src/test/nodejs/knex-tests/package.json @@ -0,0 +1,19 @@ +{ + "name": "knex-tests", + "version": "0.0.1", + "description": "Knex Query Builder tests", + "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", + "yargs": "^17.5.1" + }, + "scripts": { + "start": "ts-node src/index.ts" + } +} diff --git a/src/test/nodejs/knex-tests/src/index.ts b/src/test/nodejs/knex-tests/src/index.ts new file mode 100644 index 000000000..9b2ba0fbe --- /dev/null +++ b/src/test/nodejs/knex-tests/src/index.ts @@ -0,0 +1,71 @@ +// Copyright 2023 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. + +function runTest(host: string, port: number, database: string, test: (client) => Promise) { + const knex = require('knex')({ + client: 'pg', + connection: { + host: host, + port: port, + database: database, + ssl: false, + } + }); + runTestWithClient(knex, test); +} + +function runTestWithClient(client, test: (client) => Promise) { + test(client).then(() => client.destroy()); +} + +async function testSelect1(client) { + try { + const rows = await client.select('*').fromRaw('(select 1)'); + if (rows) { + console.log(`SELECT 1 returned: ${Object.values(rows[0])[0]}`); + } else { + console.error('Could not select 1'); + } + } catch (e) { + console.error(`Query error: ${e}`); + } +} + +async function testSelectUser(client) { + try { + const user = await client('users').where('id', 1).first(); + console.log(user); + } catch (e) { + console.error(`Query error: ${e}`); + } +} + +require('yargs') +.demand(4) +.command( + 'testSelect1 ', + 'Executes SELECT 1', + {}, + opts => runTest(opts.host, opts.port, opts.database, testSelect1) +) +.command( + 'testSelectUser ', + 'Selects a user', + {}, + opts => runTest(opts.host, opts.port, opts.database, testSelectUser) +) +.wrap(120) +.recommendCommands() +.strict() +.help().argv; diff --git a/src/test/nodejs/knex-tests/src/tsconfig.json b/src/test/nodejs/knex-tests/src/tsconfig.json new file mode 100644 index 000000000..2f720408e --- /dev/null +++ b/src/test/nodejs/knex-tests/src/tsconfig.json @@ -0,0 +1,3 @@ +{ + "noImplicitAny": false +}