diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f72fe3e3..bada2455 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,6 +1,9 @@ -name: ci +name: Checks -on: [ push, pull_request, release ] +on: + pull_request: + branches: + - main jobs: code_quality: @@ -12,62 +15,37 @@ jobs: - name: Setup Deno uses: denoland/setup-deno@v1 with: - deno-version: v1.x - + deno-version: v2.x + - name: Format run: deno fmt --check - + - name: Lint run: deno lint - - name: Documentation tests - run: deno test --doc client.ts mod.ts pool.ts client/ connection/ query/ utils/ - - test: + test_docs: runs-on: ubuntu-latest steps: - name: Clone repo uses: actions/checkout@master - name: Build tests container - run: docker-compose build tests - - - name: Run tests - run: docker-compose run tests - - - name: Run tests without typechecking - id: no_typecheck - uses: mathiasvr/command-output@v2.0.0 - with: - run: docker-compose run no_check_tests - continue-on-error: true + run: docker compose build tests - - name: Report no typechecking tests status - id: no_typecheck_status - if: steps.no_typecheck.outcome == 'success' - run: echo "name=status::success" >> $GITHUB_OUTPUT - outputs: - no_typecheck: ${{ steps.no_typecheck.outputs.stdout }} - no_typecheck_status: ${{ steps.no_typecheck_status.outputs.status }} + - name: Run doc tests + run: docker compose run doc_tests - report_warnings: - needs: [ code_quality, test ] + test: runs-on: ubuntu-latest steps: - - name: Set no-typecheck fail comment - if: ${{ needs.test.outputs.no_typecheck_status != 'success' && github.event_name == 'push' }} - uses: peter-evans/commit-comment@v3 - with: - body: | - # No typecheck tests failure + - name: Clone repo + uses: actions/checkout@master - This error was most likely caused by incorrect type stripping from the SWC crate + - name: Build tests container + run: docker compose build tests - Please report the following failure to https://github.com/denoland/deno with a reproduction of the current commit + - name: Run tests + run: docker compose run tests -
- Failure log -

-            ${{ needs.test.outputs.no_typecheck }}
-              
-
\ No newline at end of file + - name: Run tests without typechecking + run: docker compose run no_check_tests diff --git a/.github/workflows/publish_jsr.yml b/.github/workflows/publish_jsr.yml index b797ff91..1b2de0f5 100644 --- a/.github/workflows/publish_jsr.yml +++ b/.github/workflows/publish_jsr.yml @@ -11,40 +11,66 @@ jobs: timeout-minutes: 30 permissions: - contents: read + contents: write id-token: write steps: - - name: Clone repository + - name: Checkout repository uses: actions/checkout@v4 with: - submodules: true + fetch-depth: 0 - name: Set up Deno uses: denoland/setup-deno@v1 + with: + deno-version: v2.x - - name: Convert to JSR package - run: deno run -A tools/convert_to_jsr.ts + - name: Extract version from deno.json + id: get_version + run: | + VERSION=$(jq -r .version < deno.json) + echo "version=$VERSION" >> $GITHUB_OUTPUT - - name: Format + - name: Check if version tag already exists + run: | + TAG="v${{ steps.get_version.outputs.version }}" + if git rev-parse "$TAG" >/dev/null 2>&1; then + echo "🚫 Tag $TAG already exists. Aborting." + exit 1 + fi + + - name: Check Format run: deno fmt --check - + + - name: Format + run: deno fmt + - name: Lint run: deno lint - - name: Documentation tests - run: deno test --doc client.ts mod.ts pool.ts client/ connection/ query/ utils/ - - name: Build tests container - run: docker-compose build tests - + run: docker compose build tests + - name: Run tests - run: docker-compose run tests + run: docker compose run tests + + - name: Run doc tests + run: docker compose run doc_tests + + - name: Create tag for release + run: | + TAG="v${{ steps.get_version.outputs.version }}" + git config user.name "github-actions" + git config user.email "github-actions@users.noreply.github.com" + git tag "$TAG" + git push origin "$TAG" - - name: Publish (dry run) - if: startsWith(github.ref, 'refs/tags/') == false - run: deno publish --dry-run + - name: Create GitHub Release + run: | + gh release create "v${{ steps.get_version.outputs.version }}" \ + --title "v${{ steps.get_version.outputs.version }}" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Publish (real) - if: startsWith(github.ref, 'refs/tags/') - run: deno publish \ No newline at end of file + - name: Publish package + run: deno publish diff --git a/Dockerfile b/Dockerfile index c3bcd7c1..2ae96eaa 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM denoland/deno:alpine-1.40.3 +FROM denoland/deno:alpine-2.2.11 WORKDIR /app # Install wait utility @@ -11,7 +11,6 @@ USER deno # Cache external libraries # Test deps caches all main dependencies as well COPY tests/test_deps.ts tests/test_deps.ts -COPY deps.ts deps.ts RUN deno cache tests/test_deps.ts ADD . . diff --git a/LICENSE b/LICENSE index cc4afa44..43c89de1 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2018-2022 Bartłomiej Iwańczuk and Steven Guerrero +Copyright (c) 2018-2025 Bartłomiej Iwańczuk, Steven Guerrero, and Hector Ayala Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 17859ea7..fa22460a 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,40 @@ +
+ # deno-postgres -![Build Status](https://img.shields.io/github/workflow/status/denodrivers/postgres/ci?label=Build&logo=github&style=flat-square) -[![Discord server](https://img.shields.io/discord/768918486575480863?color=blue&label=Ask%20for%20help%20here&logo=discord&style=flat-square)](https://discord.gg/HEdTCvZUSf) + +
+ +
+ +![Build Status](https://img.shields.io/github/actions/workflow/status/denodrivers/postgres/ci.yml?branch=main&label=Build&logo=github&style=flat-square) +[![Discord server](https://img.shields.io/discord/768918486575480863?color=blue&label=Join%20us&logo=discord&style=flat-square)](https://discord.com/invite/HEdTCvZUSf) +[![JSR](https://jsr.io/badges/@db/postgres?style=flat-square)](https://jsr.io/@db/postgres) +[![JSR Score](https://jsr.io/badges/@db/postgres/score?style=flat-square)](https://jsr.io/@db/postgres) [![Manual](https://img.shields.io/github/v/release/denodrivers/postgres?color=orange&label=Manual&logo=deno&style=flat-square)](https://deno-postgres.com) -[![Documentation](https://img.shields.io/github/v/release/denodrivers/postgres?color=yellow&label=Documentation&logo=deno&style=flat-square)](https://doc.deno.land/https/deno.land/x/postgres/mod.ts) +[![Documentation](https://img.shields.io/github/v/release/denodrivers/postgres?color=yellow&label=Documentation&logo=deno&style=flat-square)](https://jsr.io/@db/postgres/doc) [![License](https://img.shields.io/github/license/denodrivers/postgres?color=yellowgreen&label=License&style=flat-square)](LICENSE) -A lightweight PostgreSQL driver for Deno focused on developer experience. - -`deno-postgres` is being developed inspired by the excellent work of +A lightweight PostgreSQL driver for Deno focused on developer experience.\ +`deno-postgres` is inspired by the excellent work of [node-postgres](https://github.com/brianc/node-postgres) and [pq](https://github.com/lib/pq). -## Example +
+ +## Documentation + +The documentation is available on the +[`deno-postgres`](https://deno-postgres.com/) website. + +Join the [Discord](https://discord.com/invite/HEdTCvZUSf) as well! It's a good +place to discuss bugs and features before opening issues. + +## Examples ```ts // deno run --allow-net --allow-read mod.ts -import { Client } from "https://deno.land/x/postgres/mod.ts"; +import { Client } from "jsr:@db/postgres"; const client = new Client({ user: "user", @@ -24,6 +42,7 @@ const client = new Client({ hostname: "localhost", port: 5432, }); + await client.connect(); { @@ -51,16 +70,41 @@ await client.connect(); await client.end(); ``` -For more examples, visit the documentation available at -[https://deno-postgres.com/](https://deno-postgres.com/) +## Deno compatibility -## Documentation +Due to breaking changes introduced in the unstable APIs `deno-postgres` uses, +there has been some fragmentation regarding what versions of Deno can be used +alongside the driver. + +This situation will stabilize as `deno-postgres` approach version 1.0. + +| Deno version | Min driver version | Max version | Note | +| ------------- | ------------------ | ----------- | -------------------------------------------------------------------------- | +| 1.8.x | 0.5.0 | 0.10.0 | | +| 1.9.0 | 0.11.0 | 0.11.1 | | +| 1.9.1 and up | 0.11.2 | 0.11.3 | | +| 1.11.0 and up | 0.12.0 | 0.12.0 | | +| 1.14.0 and up | 0.13.0 | 0.13.0 | | +| 1.16.0 | 0.14.0 | 0.14.3 | | +| 1.17.0 | 0.15.0 | 0.17.1 | | +| 1.40.0 | 0.17.2 | 0.19.3 | 0.19.3 and down are available in [deno.land](https://deno.land/x/postgres) | +| 2.0.0 and up | 0.19.4 | - | Available on JSR! [`@db/postgres`](https://jsr.io/@db/postgres) | + +## Breaking changes -The documentation is available on the deno-postgres website -[https://deno-postgres.com/](https://deno-postgres.com/) +Although `deno-postgres` is reasonably stable and robust, it is a WIP, and we're +still exploring the design. Expect some breaking changes as we reach version 1.0 +and enhance the feature set. Please check the +[Releases](https://github.com/denodrivers/postgres/releases) for more info on +breaking changes. Please reach out if there are any undocumented breaking +changes. -Join the [Discord](https://discord.gg/HEdTCvZUSf) as well! It's a good place to -discuss bugs and features before opening issues. +## Found issues? + +Please +[file an issue](https://github.com/denodrivers/postgres/issues/new/choose) with +any problems with the driver. If you would like to help, please look at the +issues as well. You can pick up one of them and try to implement it. ## Contributing @@ -76,8 +120,8 @@ discuss bugs and features before opening issues. it to run the linter and formatter locally - https://deno.land/ - - `deno upgrade --version 1.40.0` - - `dvm install 1.40.0 && dvm use 1.40.0` + - `deno upgrade stable` + - `dvm install stable && dvm use stable` - You don't need to install Postgres locally on your machine to test the library; it will run as a service in the Docker container when you build it @@ -89,8 +133,8 @@ result assertions. To run the tests, run the following commands: -1. `docker-compose build tests` -2. `docker-compose run tests` +1. `docker compose build tests` +2. `docker compose run tests` The build step will check linting and formatting as well and report it to the command line @@ -99,8 +143,8 @@ It is recommended that you don't rely on any previously initialized data for your tests instead create all the data you need at the moment of running the tests -For example, the following test will create a temporal table that will disappear -once the test has been completed +For example, the following test will create a temporary table that will +disappear once the test has been completed ```ts Deno.test("INSERT works correctly", async () => { @@ -137,25 +181,6 @@ a local testing environment, as shown in the following steps: 3. Run the tests manually by using the command\ `deno test -A` -## Deno compatibility - -Due to breaking changes introduced in the unstable APIs `deno-postgres` uses, -there has been some fragmentation regarding what versions of Deno can be used -alongside the driver. - -This situation will stabilize as `std` and `deno-postgres` approach version 1.0. - -| Deno version | Min driver version | Max driver version | Note | -| ------------- | ------------------ | ------------------ | -------------------- | -| 1.8.x | 0.5.0 | 0.10.0 | | -| 1.9.0 | 0.11.0 | 0.11.1 | | -| 1.9.1 and up | 0.11.2 | 0.11.3 | | -| 1.11.0 and up | 0.12.0 | 0.12.0 | | -| 1.14.0 and up | 0.13.0 | 0.13.0 | | -| 1.16.0 | 0.14.0 | 0.14.3 | | -| 1.17.0 | 0.15.0 | 0.17.1 | | -| 1.40.0 | 0.17.2 | | Now available on JSR | - ## Contributing guidelines When contributing to the repository, make sure to: @@ -181,5 +206,5 @@ preserved their individual licenses and copyrights. Everything is licensed under the MIT License. -All additional work is copyright 2018 - 2024 — Bartłomiej Iwańczuk, Steven +All additional work is copyright 2018 - 2025 — Bartłomiej Iwańczuk, Steven Guerrero, Hector Ayala — All rights reserved. diff --git a/client.ts b/client.ts index 7635c6a3..f064e976 100644 --- a/client.ts +++ b/client.ts @@ -105,47 +105,57 @@ export abstract class QueryClient { * In order to create a transaction, use the `createTransaction` method in your client as follows: * * ```ts - * import { Client } from "https://deno.land/x/postgres/mod.ts"; + * import { Client } from "jsr:@db/postgres"; * const client = new Client(); * const transaction = client.createTransaction("my_transaction_name"); * * await transaction.begin(); * // All statements between begin and commit will happen inside the transaction * await transaction.commit(); // All changes are saved + * await client.end(); * ``` * * All statements that fail in query execution will cause the current transaction to abort and release * the client without applying any of the changes that took place inside it * * ```ts - * import { Client } from "https://deno.land/x/postgres/mod.ts"; + * import { Client } from "jsr:@db/postgres"; * const client = new Client(); - * const transaction = client.createTransaction("transaction"); + * const transaction = client.createTransaction("cool_transaction"); * * await transaction.begin(); - * await transaction.queryArray`INSERT INTO MY_TABLE (X) VALUES ${"some_value"}`; + * * try { - * await transaction.queryArray`SELECT []`; // Invalid syntax, transaction aborted, changes won't be applied - * }catch(e){ - * await transaction.commit(); // Will throw, current transaction has already finished + * try { + * await transaction.queryArray`SELECT []`; // Invalid syntax, transaction aborted, changes won't be applied + * } catch (e) { + * await transaction.commit(); // Will throw, current transaction has already finished + * } + * } catch (e) { + * console.log(e); * } + * + * await client.end(); * ``` * * This however, only happens if the error is of execution in nature, validation errors won't abort * the transaction * * ```ts - * import { Client } from "https://deno.land/x/postgres/mod.ts"; + * import { Client } from "jsr:@db/postgres"; * const client = new Client(); - * const transaction = client.createTransaction("transaction"); + * const transaction = client.createTransaction("awesome_transaction"); * * await transaction.begin(); - * await transaction.queryArray`INSERT INTO MY_TABLE (X) VALUES ${"some_value"}`; + * * try { * await transaction.rollback("unexistent_savepoint"); // Validation error - * } catch(e) { + * } catch (e) { + * console.log(e); * await transaction.commit(); // Transaction will end, changes will be saved * } + * + * await client.end(); * ``` * * A transaction has many options to ensure modifications made to the database are safe and @@ -160,7 +170,7 @@ export abstract class QueryClient { * - Repeatable read: This isolates the transaction in a way that any external changes to the data we are reading * won't be visible inside the transaction until it has finished * ```ts - * import { Client } from "https://deno.land/x/postgres/mod.ts"; + * import { Client } from "jsr:@db/postgres"; * const client = new Client(); * const transaction = await client.createTransaction("my_transaction", { isolation_level: "repeatable_read" }); * ``` @@ -168,7 +178,7 @@ export abstract class QueryClient { * - Serializable: This isolation level prevents the current transaction from making persistent changes * if the data they were reading at the beginning of the transaction has been modified (recommended) * ```ts - * import { Client } from "https://deno.land/x/postgres/mod.ts"; + * import { Client } from "jsr:@db/postgres"; * const client = new Client(); * const transaction = await client.createTransaction("my_transaction", { isolation_level: "serializable" }); * ``` @@ -181,7 +191,7 @@ export abstract class QueryClient { * is to in conjuction with the repeatable read isolation, ensuring the data you are reading does not change * during the transaction, specially useful for data extraction * ```ts - * import { Client } from "https://deno.land/x/postgres/mod.ts"; + * import { Client } from "jsr:@db/postgres"; * const client = new Client(); * const transaction = await client.createTransaction("my_transaction", { read_only: true }); * ``` @@ -192,14 +202,19 @@ export abstract class QueryClient { * you can do the following: * * ```ts - * import { Client } from "https://deno.land/x/postgres/mod.ts"; + * import { Client } from "jsr:@db/postgres"; * const client_1 = new Client(); * const client_2 = new Client(); * const transaction_1 = client_1.createTransaction("transaction_1"); * + * await transaction_1.begin(); + * * const snapshot = await transaction_1.getSnapshot(); * const transaction_2 = client_2.createTransaction("new_transaction", { isolation_level: "repeatable_read", snapshot }); * // transaction_2 now shares the same starting state that transaction_1 had + * + * await client_1.end(); + * await client_2.end(); * ``` * * https://www.postgresql.org/docs/14/tutorial-transactions.html @@ -260,9 +275,14 @@ export abstract class QueryClient { * Execute queries and retrieve the data as array entries. It supports a generic in order to type the entries retrieved by the query * * ```ts - * import { Client } from "https://deno.land/x/postgres/mod.ts"; + * import { Client } from "jsr:@db/postgres"; * const my_client = new Client(); * + * await my_client.queryArray`CREATE TABLE IF NOT EXISTS CLIENTS ( + * id SERIAL PRIMARY KEY, + * name TEXT NOT NULL + * )` + * * const { rows: rows1 } = await my_client.queryArray( * "SELECT ID, NAME FROM CLIENTS" * ); // Array @@ -270,6 +290,8 @@ export abstract class QueryClient { * const { rows: rows2 } = await my_client.queryArray<[number, string]>( * "SELECT ID, NAME FROM CLIENTS" * ); // Array<[number, string]> + * + * await my_client.end(); * ``` */ async queryArray>( @@ -280,12 +302,13 @@ export abstract class QueryClient { * Use the configuration object for more advance options to execute the query * * ```ts - * import { Client } from "https://deno.land/x/postgres/mod.ts"; + * import { Client } from "jsr:@db/postgres"; * const my_client = new Client(); * const { rows } = await my_client.queryArray<[number, string]>({ * text: "SELECT ID, NAME FROM CLIENTS", * name: "select_clients", * }); // Array<[number, string]> + * await my_client.end(); * ``` */ async queryArray>( @@ -295,12 +318,14 @@ export abstract class QueryClient { * Execute prepared statements with template strings * * ```ts - * import { Client } from "https://deno.land/x/postgres/mod.ts"; + * import { Client } from "jsr:@db/postgres"; * const my_client = new Client(); * * const id = 12; * // Array<[number, string]> * const {rows} = await my_client.queryArray<[number, string]>`SELECT ID, NAME FROM CLIENTS WHERE ID = ${id}`; + * + * await my_client.end(); * ``` */ async queryArray>( @@ -343,7 +368,7 @@ export abstract class QueryClient { * Executed queries and retrieve the data as object entries. It supports a generic in order to type the entries retrieved by the query * * ```ts - * import { Client } from "https://deno.land/x/postgres/mod.ts"; + * import { Client } from "jsr:@db/postgres"; * const my_client = new Client(); * * const { rows: rows1 } = await my_client.queryObject( @@ -353,6 +378,8 @@ export abstract class QueryClient { * const { rows: rows2 } = await my_client.queryObject<{id: number, name: string}>( * "SELECT ID, NAME FROM CLIENTS" * ); // Array<{id: number, name: string}> + * + * await my_client.end(); * ``` */ async queryObject( @@ -363,7 +390,7 @@ export abstract class QueryClient { * Use the configuration object for more advance options to execute the query * * ```ts - * import { Client } from "https://deno.land/x/postgres/mod.ts"; + * import { Client } from "jsr:@db/postgres"; * const my_client = new Client(); * * const { rows: rows1 } = await my_client.queryObject( @@ -376,6 +403,8 @@ export abstract class QueryClient { * fields: ["personal_id", "complete_name"], * }); * console.log(rows2); // [{personal_id: 78, complete_name: "Frank"}, {personal_id: 15, complete_name: "Sarah"}] + * + * await my_client.end(); * ``` */ async queryObject( @@ -385,11 +414,12 @@ export abstract class QueryClient { * Execute prepared statements with template strings * * ```ts - * import { Client } from "https://deno.land/x/postgres/mod.ts"; + * import { Client } from "jsr:@db/postgres"; * const my_client = new Client(); * const id = 12; * // Array<{id: number, name: string}> * const { rows } = await my_client.queryObject<{id: number, name: string}>`SELECT ID, NAME FROM CLIENTS WHERE ID = ${id}`; + * await my_client.end(); * ``` */ async queryObject( @@ -447,10 +477,10 @@ export abstract class QueryClient { * statements asynchronously * * ```ts - * import { Client } from "https://deno.land/x/postgres/mod.ts"; + * import { Client } from "jsr:@db/postgres"; * const client = new Client(); * await client.connect(); - * await client.queryArray`UPDATE MY_TABLE SET MY_FIELD = 0`; + * await client.queryArray`SELECT * FROM CLIENTS`; * await client.end(); * ``` * @@ -458,18 +488,17 @@ export abstract class QueryClient { * for concurrency capabilities check out connection pools * * ```ts - * import { Client } from "https://deno.land/x/postgres/mod.ts"; + * import { Client } from "jsr:@db/postgres"; * const client_1 = new Client(); * await client_1.connect(); * // Even if operations are not awaited, they will be executed in the order they were * // scheduled - * client_1.queryArray`UPDATE MY_TABLE SET MY_FIELD = 0`; - * client_1.queryArray`DELETE FROM MY_TABLE`; + * client_1.queryArray`DELETE FROM CLIENTS`; * * const client_2 = new Client(); * await client_2.connect(); * // `client_2` will execute it's queries in parallel to `client_1` - * const {rows: result} = await client_2.queryArray`SELECT * FROM MY_TABLE`; + * const {rows: result} = await client_2.queryArray`SELECT * FROM CLIENTS`; * * await client_1.end(); * await client_2.end(); @@ -515,4 +544,8 @@ export class PoolClient extends QueryClient { // Cleanup all session related metadata this.resetSessionMetadata(); } + + [Symbol.dispose]() { + this.release(); + } } diff --git a/client/error.ts b/client/error.ts index a7b97566..fa759980 100644 --- a/client/error.ts +++ b/client/error.ts @@ -1,4 +1,4 @@ -import { type Notice } from "../connection/message.ts"; +import type { Notice } from "../connection/message.ts"; /** * A connection error @@ -20,7 +20,7 @@ export class ConnectionParamsError extends Error { /** * Create a new ConnectionParamsError */ - constructor(message: string, cause?: Error) { + constructor(message: string, cause?: unknown) { super(message, { cause }); this.name = "ConnectionParamsError"; } @@ -35,12 +35,18 @@ export class PostgresError extends Error { */ public fields: Notice; + /** + * The query that caused the error + */ + public query: string | undefined; + /** * Create a new PostgresError */ - constructor(fields: Notice) { + constructor(fields: Notice, query?: string) { super(fields.message); this.fields = fields; + this.query = query; this.name = "PostgresError"; } } diff --git a/connection/auth.ts b/connection/auth.ts index c32e7b88..e77b8830 100644 --- a/connection/auth.ts +++ b/connection/auth.ts @@ -1,9 +1,10 @@ -import { crypto, hex } from "../deps.ts"; +import { crypto } from "@std/crypto/crypto"; +import { encodeHex } from "@std/encoding/hex"; const encoder = new TextEncoder(); async function md5(bytes: Uint8Array): Promise { - return hex.encodeHex(await crypto.subtle.digest("MD5", bytes)); + return encodeHex(await crypto.subtle.digest("MD5", bytes)); } // AuthenticationMD5Password diff --git a/connection/connection.ts b/connection/connection.ts index c062553c..9c0e66a2 100644 --- a/connection/connection.ts +++ b/connection/connection.ts @@ -26,14 +26,8 @@ * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import { - bold, - BufReader, - BufWriter, - delay, - joinPath, - yellow, -} from "../deps.ts"; +import { join as joinPath } from "@std/path"; +import { bold, rgb24, yellow } from "@std/fmt/colors"; import { DeferredStack } from "../utils/deferred.ts"; import { getSocketName, readUInt32BE } from "../utils/utils.ts"; import { PacketWriter } from "./packet.ts"; @@ -53,7 +47,7 @@ import { type QueryResult, ResultType, } from "../query/query.ts"; -import { type ClientConfiguration } from "./connection_params.ts"; +import type { ClientConfiguration } from "./connection_params.ts"; import * as scram from "./scram.ts"; import { ConnectionError, @@ -68,6 +62,7 @@ import { INCOMING_TLS_MESSAGES, } from "./message_code.ts"; import { hashMd5Password } from "./auth.ts"; +import { isDebugOptionEnabled } from "../debug.ts"; // Work around unstable limitation type ConnectOptions = @@ -97,7 +92,25 @@ function assertSuccessfulAuthentication(auth_message: Message) { } function logNotice(notice: Notice) { - console.error(`${bold(yellow(notice.severity))}: ${notice.message}`); + if (notice.severity === "INFO") { + console.info( + `[ ${bold(rgb24(notice.severity, 0xff99ff))} ] : ${notice.message}`, + ); + } else if (notice.severity === "NOTICE") { + console.info(`[ ${bold(yellow(notice.severity))} ] : ${notice.message}`); + } else if (notice.severity === "WARNING") { + console.warn( + `[ ${bold(rgb24(notice.severity, 0xff9900))} ] : ${notice.message}`, + ); + } +} + +function logQuery(query: string) { + console.info(`[ ${bold(rgb24("QUERY", 0x00ccff))} ] : ${query}`); +} + +function logResults(rows: unknown[]) { + console.info(`[ ${bold(rgb24("RESULTS", 0x00cc00))} ] :`, rows); } const decoder = new TextDecoder(); @@ -107,8 +120,6 @@ const encoder = new TextEncoder(); // - Refactor properties to not be lazily initialized // or to handle their undefined value export class Connection { - #bufReader!: BufReader; - #bufWriter!: BufWriter; #conn!: Deno.Conn; connected = false; #connection_params: ClientConfiguration; @@ -122,6 +133,7 @@ export class Connection { #secretKey?: number; #tls?: boolean; #transport?: "tcp" | "socket"; + #connWritable!: WritableStreamDefaultWriter; get pid(): number | undefined { return this.#pid; @@ -145,13 +157,39 @@ export class Connection { this.#onDisconnection = disconnection_callback; } + /** + * Read p.length bytes into the buffer + */ + async #readFull(p: Uint8Array): Promise { + let bytes_read = 0; + while (bytes_read < p.length) { + try { + const read_result = await this.#conn.read(p.subarray(bytes_read)); + if (read_result === null) { + if (bytes_read === 0) { + return; + } else { + throw new ConnectionError("Failed to read bytes from socket"); + } + } + bytes_read += read_result; + } catch (e) { + if (e instanceof Deno.errors.ConnectionReset) { + throw new ConnectionError("The session was terminated unexpectedly"); + } + throw e; + } + } + } + /** * Read single message sent by backend */ async #readMessage(): Promise { // Clear buffer before reading the message type this.#message_header.fill(0); - await this.#bufReader.readFull(this.#message_header); + await this.#readFull(this.#message_header); + const type = decoder.decode(this.#message_header.slice(0, 1)); // TODO // Investigate if the ascii terminator is the best way to check for a broken @@ -167,7 +205,7 @@ export class Connection { } const length = readUInt32BE(this.#message_header, 1) - 4; const body = new Uint8Array(length); - await this.#bufReader.readFull(body); + await this.#readFull(body); return new Message(type, length, body); } @@ -177,8 +215,7 @@ export class Connection { writer.clear(); writer.addInt32(8).addInt32(80877103).join(); - await this.#bufWriter.write(writer.flush()); - await this.#bufWriter.flush(); + await this.#connWritable.write(writer.flush()); const response = new Uint8Array(1); await this.#conn.read(response); @@ -234,8 +271,7 @@ export class Connection { const finalBuffer = writer.addInt32(bodyLength).add(bodyBuffer).join(); - await this.#bufWriter.write(finalBuffer); - await this.#bufWriter.flush(); + await this.#connWritable.write(finalBuffer); return await this.#readMessage(); } @@ -244,8 +280,7 @@ export class Connection { // @ts-expect-error This will throw in runtime if the options passed to it are socket related and deno is running // on stable this.#conn = await Deno.connect(options); - this.#bufWriter = new BufWriter(this.#conn); - this.#bufReader = new BufReader(this.#conn); + this.#connWritable = this.#conn.writable.getWriter(); } async #openSocketConnection(path: string, port: number) { @@ -275,12 +310,11 @@ export class Connection { } async #openTlsConnection( - connection: Deno.Conn, + connection: Deno.TcpConn, options: { hostname: string; caCerts: string[] }, ) { this.#conn = await Deno.startTls(connection, options); - this.#bufWriter = new BufWriter(this.#conn); - this.#bufReader = new BufReader(this.#conn); + this.#connWritable = this.#conn.writable.getWriter(); } #resetConnectionMetadata() { @@ -318,7 +352,7 @@ export class Connection { this.#tls = undefined; this.#transport = "socket"; } else { - // A BufWriter needs to be available in order to check if the server accepts TLS connections + // A writer needs to be available in order to check if the server accepts TLS connections await this.#openConnection({ hostname, port, transport: "tcp" }); this.#tls = false; this.#transport = "tcp"; @@ -334,7 +368,9 @@ export class Connection { // https://www.postgresql.org/docs/14/protocol-flow.html#id-1.10.5.7.11 if (accepts_tls) { try { - await this.#openTlsConnection(this.#conn, { + // TODO: handle connection type without castinggaa + // https://github.com/denoland/deno/issues/10200 + await this.#openTlsConnection(this.#conn as Deno.TcpConn, { hostname, caCerts: caCertificates, }); @@ -343,7 +379,7 @@ export class Connection { if (!tls_enforced) { console.error( bold(yellow("TLS connection failed with message: ")) + - e.message + + (e instanceof Error ? e.message : e) + "\n" + bold("Defaulting to non-encrypted connection"), ); @@ -370,10 +406,14 @@ export class Connection { } catch (e) { // Make sure to close the connection before erroring or reseting this.#closeConnection(); - if (e instanceof Deno.errors.InvalidData && tls_enabled) { + if ( + (e instanceof Deno.errors.InvalidData || + e instanceof Deno.errors.BadResource) && tls_enabled + ) { if (tls_enforced) { throw new Error( - "The certificate used to secure the TLS connection is invalid.", + "The certificate used to secure the TLS connection is invalid: " + + e.message, ); } else { console.error( @@ -411,6 +451,8 @@ export class Connection { } case INCOMING_AUTHENTICATION_MESSAGES.PARAMETER_STATUS: break; + case INCOMING_AUTHENTICATION_MESSAGES.NOTICE: + break; default: throw new Error(`Unknown response for startup: ${message.type}`); } @@ -443,7 +485,7 @@ export class Connection { let reconnection_attempts = 0; const max_reconnections = this.#connection_params.connection.attempts; - let error: Error | undefined; + let error: unknown | undefined; // If no connection has been established and the reconnection attempts are // set to zero, attempt to connect at least once if (!is_reconnection && this.#connection_params.connection.attempts === 0) { @@ -467,7 +509,7 @@ export class Connection { } if (interval > 0) { - await delay(interval); + await new Promise((resolve) => setTimeout(resolve, interval)); } } try { @@ -541,8 +583,7 @@ export class Connection { const password = this.#connection_params.password || ""; const buffer = this.#packetWriter.addCString(password).flush(0x70); - await this.#bufWriter.write(buffer); - await this.#bufWriter.flush(); + await this.#connWritable.write(buffer); return this.#readMessage(); } @@ -563,8 +604,7 @@ export class Connection { ); const buffer = this.#packetWriter.addCString(password).flush(0x70); - await this.#bufWriter.write(buffer); - await this.#bufWriter.flush(); + await this.#connWritable.write(buffer); return this.#readMessage(); } @@ -591,8 +631,7 @@ export class Connection { this.#packetWriter.addCString("SCRAM-SHA-256"); this.#packetWriter.addInt32(clientFirstMessage.length); this.#packetWriter.addString(clientFirstMessage); - this.#bufWriter.write(this.#packetWriter.flush(0x70)); - this.#bufWriter.flush(); + this.#connWritable.write(this.#packetWriter.flush(0x70)); const maybe_sasl_continue = await this.#readMessage(); switch (maybe_sasl_continue.type) { @@ -619,8 +658,7 @@ export class Connection { this.#packetWriter.clear(); this.#packetWriter.addString(await client.composeResponse()); - this.#bufWriter.write(this.#packetWriter.flush(0x70)); - this.#bufWriter.flush(); + this.#connWritable.write(this.#packetWriter.flush(0x70)); const maybe_sasl_final = await this.#readMessage(); switch (maybe_sasl_final.type) { @@ -656,8 +694,7 @@ export class Connection { const buffer = this.#packetWriter.addCString(query.text).flush(0x51); - await this.#bufWriter.write(buffer); - await this.#bufWriter.flush(); + await this.#connWritable.write(buffer); let result; if (query.result_type === ResultType.ARRAY) { @@ -666,7 +703,7 @@ export class Connection { result = new QueryObjectResult(query); } - let error: Error | undefined; + let error: unknown | undefined; let current_message = await this.#readMessage(); // Process messages until ready signal is sent @@ -674,7 +711,15 @@ export class Connection { while (current_message.type !== INCOMING_QUERY_MESSAGES.READY) { switch (current_message.type) { case ERROR_MESSAGE: - error = new PostgresError(parseNoticeMessage(current_message)); + error = new PostgresError( + parseNoticeMessage(current_message), + isDebugOptionEnabled( + "queryInError", + this.#connection_params.controls?.debug, + ) + ? query.text + : undefined, + ); break; case INCOMING_QUERY_MESSAGES.COMMAND_COMPLETE: { result.handleCommandComplete( @@ -695,7 +740,14 @@ export class Connection { break; case INCOMING_QUERY_MESSAGES.NOTICE_WARNING: { const notice = parseNoticeMessage(current_message); - logNotice(notice); + if ( + isDebugOptionEnabled( + "notices", + this.#connection_params.controls?.debug, + ) + ) { + logNotice(notice); + } result.warnings.push(notice); break; } @@ -731,7 +783,7 @@ export class Connection { .addCString(query.text) .addInt16(0) .flush(0x50); - await this.#bufWriter.write(buffer); + await this.#connWritable.write(buffer); } async #appendArgumentsToMessage(query: Query) { @@ -748,16 +800,16 @@ export class Connection { if (hasBinaryArgs) { this.#packetWriter.addInt16(query.args.length); - query.args.forEach((arg) => { + for (const arg of query.args) { this.#packetWriter.addInt16(arg instanceof Uint8Array ? 1 : 0); - }); + } } else { this.#packetWriter.addInt16(0); } this.#packetWriter.addInt16(query.args.length); - query.args.forEach((arg) => { + for (const arg of query.args) { if (arg === null || typeof arg === "undefined") { this.#packetWriter.addInt32(-1); } else if (arg instanceof Uint8Array) { @@ -768,11 +820,11 @@ export class Connection { this.#packetWriter.addInt32(byteLength); this.#packetWriter.addString(arg); } - }); + } this.#packetWriter.addInt16(0); const buffer = this.#packetWriter.flush(0x42); - await this.#bufWriter.write(buffer); + await this.#connWritable.write(buffer); } /** @@ -783,7 +835,7 @@ export class Connection { this.#packetWriter.clear(); const buffer = this.#packetWriter.addCString("P").flush(0x44); - await this.#bufWriter.write(buffer); + await this.#connWritable.write(buffer); } async #appendExecuteToMessage() { @@ -793,14 +845,14 @@ export class Connection { .addCString("") // unnamed portal .addInt32(0) .flush(0x45); - await this.#bufWriter.write(buffer); + await this.#connWritable.write(buffer); } async #appendSyncToMessage() { this.#packetWriter.clear(); const buffer = this.#packetWriter.flush(0x53); - await this.#bufWriter.write(buffer); + await this.#connWritable.write(buffer); } // TODO @@ -819,6 +871,12 @@ export class Connection { /** * https://www.postgresql.org/docs/14/protocol-flow.html#PROTOCOL-FLOW-EXT-QUERY */ + async #preparedQuery( + query: Query, + ): Promise; + async #preparedQuery( + query: Query, + ): Promise; async #preparedQuery( query: Query, ): Promise { @@ -832,8 +890,6 @@ export class Connection { // The execute response contains the portal in which the query will be run and how many rows should it return await this.#appendExecuteToMessage(); await this.#appendSyncToMessage(); - // send all messages to backend - await this.#bufWriter.flush(); let result; if (query.result_type === ResultType.ARRAY) { @@ -842,13 +898,21 @@ export class Connection { result = new QueryObjectResult(query); } - let error: Error | undefined; + let error: unknown | undefined; let current_message = await this.#readMessage(); while (current_message.type !== INCOMING_QUERY_MESSAGES.READY) { switch (current_message.type) { case ERROR_MESSAGE: { - error = new PostgresError(parseNoticeMessage(current_message)); + error = new PostgresError( + parseNoticeMessage(current_message), + isDebugOptionEnabled( + "queryInError", + this.#connection_params.controls?.debug, + ) + ? query.text + : undefined, + ); break; } case INCOMING_QUERY_MESSAGES.BIND_COMPLETE: @@ -872,7 +936,14 @@ export class Connection { break; case INCOMING_QUERY_MESSAGES.NOTICE_WARNING: { const notice = parseNoticeMessage(current_message); - logNotice(notice); + if ( + isDebugOptionEnabled( + "notices", + this.#connection_params.controls?.debug, + ) + ) { + logNotice(notice); + } result.warnings.push(notice); break; } @@ -911,11 +982,23 @@ export class Connection { await this.#queryLock.pop(); try { + if ( + isDebugOptionEnabled("queries", this.#connection_params.controls?.debug) + ) { + logQuery(query.text); + } + let result: QueryArrayResult | QueryObjectResult; if (query.args.length === 0) { - return await this.#simpleQuery(query); + result = await this.#simpleQuery(query); } else { - return await this.#preparedQuery(query); + result = await this.#preparedQuery(query); + } + if ( + isDebugOptionEnabled("results", this.#connection_params.controls?.debug) + ) { + logResults(result.rows); } + return result; } catch (e) { if (e instanceof ConnectionError) { await this.end(); @@ -929,9 +1012,9 @@ export class Connection { async end(): Promise { if (this.connected) { const terminationMessage = new Uint8Array([0x58, 0x00, 0x00, 0x00, 0x04]); - await this.#bufWriter.write(terminationMessage); + await this.#connWritable.write(terminationMessage); try { - await this.#bufWriter.flush(); + await this.#connWritable.ready; } catch (_e) { // This steps can fail if the underlying connection was closed ungracefully } finally { diff --git a/connection/connection_params.ts b/connection/connection_params.ts index ec4d07eb..a55fb804 100644 --- a/connection/connection_params.ts +++ b/connection/connection_params.ts @@ -1,7 +1,9 @@ import { parseConnectionUri } from "../utils/utils.ts"; import { ConnectionParamsError } from "../client/error.ts"; -import { fromFileUrl, isAbsolute } from "../deps.ts"; -import { OidKey } from "../query/oid.ts"; +import { fromFileUrl, isAbsolute } from "@std/path"; +import type { OidType } from "../query/oid.ts"; +import type { DebugControls } from "../debug.ts"; +import type { ParseArrayFunction } from "../query/array_parser.ts"; /** * The connection string must match the following URI structure. All parameters but database and user are optional @@ -92,21 +94,40 @@ export interface TLSOptions { caCertificates: string[]; } +/** + * The strategy to use when decoding results data + */ export type DecodeStrategy = "string" | "auto"; +/** + * A dictionary of functions used to decode (parse) column field values from string to a custom type. These functions will + * take precedence over the {@linkcode DecodeStrategy}. Each key in the dictionary is the column OID type number or Oid type name, + * and the value is the decoder function. + */ export type Decoders = { - [key in number | OidKey]?: DecoderFunction; + [key in number | OidType]?: DecoderFunction; }; /** * A decoder function that takes a string value and returns a parsed value of some type. - * the Oid is also passed to the function for reference + * + * @param value The string value to parse + * @param oid The OID of the column type the value is from + * @param parseArray A helper function that parses SQL array-formatted strings and parses each array value using a transform function. */ -export type DecoderFunction = (value: string, oid: number) => unknown; +export type DecoderFunction = ( + value: string, + oid: number, + parseArray: ParseArrayFunction, +) => unknown; /** * Control the behavior for the client instance */ export type ClientControls = { + /** + * Debugging options + */ + debug?: DebugControls; /** * The strategy to use when decoding results data * @@ -128,15 +149,14 @@ export type ClientControls = { * * @example * ```ts - * import dayjs from 'https://esm.sh/dayjs'; - * import { Oid,Decoders } from '../mod.ts' + * import { Oid, Decoders } from '../mod.ts' * * { * const decoders: Decoders = { * // 16 = Oid.bool : convert all boolean values to numbers * '16': (value: string) => value === 't' ? 1 : 0, - * // 1082 = Oid.date : convert all dates to dayjs objects - * 1082: (value: string) => dayjs(value), + * // 1082 = Oid.date : convert all dates to Date objects + * 1082: (value: string) => new Date(value), * // 23 = Oid.int4 : convert all integers to positive numbers * [Oid.int4]: (value: string) => Math.max(0, parseInt(value || '0', 10)), * } @@ -399,7 +419,15 @@ export function createParams( try { pgEnv = getPgEnv(); } catch (e) { - if (e instanceof Deno.errors.PermissionDenied) { + // In Deno v1, Deno permission errors resulted in a Deno.errors.PermissionDenied exception. In Deno v2, a new + // Deno.errors.NotCapable exception was added to replace this. The "in" check makes this code safe for both Deno + // 1 and Deno 2 + if ( + e instanceof + ("NotCapable" in Deno.errors + ? Deno.errors.NotCapable + : Deno.errors.PermissionDenied) + ) { has_env_access = false; } else { throw e; diff --git a/connection/message_code.ts b/connection/message_code.ts index ede4ed09..979fc1a3 100644 --- a/connection/message_code.ts +++ b/connection/message_code.ts @@ -24,6 +24,7 @@ export const INCOMING_AUTHENTICATION_MESSAGES = { BACKEND_KEY: "K", PARAMETER_STATUS: "S", READY: "Z", + NOTICE: "N", } as const; export const INCOMING_TLS_MESSAGES = { diff --git a/connection/packet.ts b/connection/packet.ts index 36abae18..2d93f695 100644 --- a/connection/packet.ts +++ b/connection/packet.ts @@ -25,7 +25,7 @@ * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import { copy } from "../deps.ts"; +import { copy } from "@std/bytes/copy"; import { readInt16BE, readInt32BE } from "../utils/utils.ts"; export class PacketReader { diff --git a/connection/scram.ts b/connection/scram.ts index 1ef2661e..e4e18c32 100644 --- a/connection/scram.ts +++ b/connection/scram.ts @@ -1,4 +1,4 @@ -import { base64 } from "../deps.ts"; +import { decodeBase64, encodeBase64 } from "@std/encoding/base64"; /** Number of random bytes used to generate a nonce */ const defaultNonceSize = 16; @@ -132,7 +132,7 @@ function escape(str: string): string { } function generateRandomNonce(size: number): string { - return base64.encodeBase64(crypto.getRandomValues(new Uint8Array(size))); + return encodeBase64(crypto.getRandomValues(new Uint8Array(size))); } function parseScramAttributes(message: string): Record { @@ -144,10 +144,8 @@ function parseScramAttributes(message: string): Record { throw new Error(Reason.BadMessage); } - // TODO - // Replace with String.prototype.substring - const key = entry.substr(0, pos); - const value = entry.substr(pos + 1); + const key = entry.substring(0, pos); + const value = entry.slice(pos + 1); attrs[key] = value; } @@ -221,7 +219,7 @@ export class Client { throw new Error(Reason.BadSalt); } try { - salt = base64.decodeBase64(attrs.s); + salt = decodeBase64(attrs.s); } catch { throw new Error(Reason.BadSalt); } @@ -261,7 +259,7 @@ export class Client { this.#auth_message += "," + responseWithoutProof; - const proof = base64.encodeBase64( + const proof = encodeBase64( computeScramProof( await computeScramSignature( this.#auth_message, @@ -294,7 +292,7 @@ export class Client { throw new Error(attrs.e ?? Reason.Rejected); } - const verifier = base64.encodeBase64( + const verifier = encodeBase64( await computeScramSignature( this.#auth_message, this.#key_signatures.server, diff --git a/debug.ts b/debug.ts new file mode 100644 index 00000000..1b477888 --- /dev/null +++ b/debug.ts @@ -0,0 +1,30 @@ +/** + * Controls debugging behavior. If set to `true`, all debug options are enabled. + * If set to `false`, all debug options are disabled. Can also be an object with + * specific debug options to enable. + * + * {@default false} + */ +export type DebugControls = DebugOptions | boolean; + +type DebugOptions = { + /** Log all queries */ + queries?: boolean; + /** Log all INFO, NOTICE, and WARNING raised database messages */ + notices?: boolean; + /** Log all results */ + results?: boolean; + /** Include the SQL query that caused an error in the PostgresError object */ + queryInError?: boolean; +}; + +export const isDebugOptionEnabled = ( + option: keyof DebugOptions, + options?: DebugControls, +): boolean => { + if (typeof options === "boolean") { + return options; + } + + return !!options?.[option]; +}; diff --git a/deno.json b/deno.json index a25df52d..35e10847 100644 --- a/deno.json +++ b/deno.json @@ -1,6 +1,14 @@ { - "lock": false, - "name": "@bartlomieju/postgres", - "version": "0.18.0", - "exports": "./mod.ts" + "name": "@db/postgres", + "version": "0.19.5", + "license": "MIT", + "exports": "./mod.ts", + "imports": { + "@std/bytes": "jsr:@std/bytes@^1.0.5", + "@std/crypto": "jsr:@std/crypto@^1.0.4", + "@std/encoding": "jsr:@std/encoding@^1.0.9", + "@std/fmt": "jsr:@std/fmt@^1.0.6", + "@std/path": "jsr:@std/path@^1.0.8" + }, + "lock": false } diff --git a/deps.ts b/deps.ts deleted file mode 100644 index 1dcd6cea..00000000 --- a/deps.ts +++ /dev/null @@ -1,14 +0,0 @@ -export * as base64 from "https://deno.land/std@0.214.0/encoding/base64.ts"; -export * as hex from "https://deno.land/std@0.214.0/encoding/hex.ts"; -export { parse as parseDate } from "https://deno.land/std@0.214.0/datetime/parse.ts"; -export { BufReader } from "https://deno.land/std@0.214.0/io/buf_reader.ts"; -export { BufWriter } from "https://deno.land/std@0.214.0/io/buf_writer.ts"; -export { copy } from "https://deno.land/std@0.214.0/bytes/copy.ts"; -export { crypto } from "https://deno.land/std@0.214.0/crypto/crypto.ts"; -export { delay } from "https://deno.land/std@0.214.0/async/delay.ts"; -export { bold, yellow } from "https://deno.land/std@0.214.0/fmt/colors.ts"; -export { - fromFileUrl, - isAbsolute, - join as joinPath, -} from "https://deno.land/std@0.214.0/path/mod.ts"; diff --git a/docker-compose.yml b/docker-compose.yml index be919039..a665103d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,3 @@ -version: '3.8' - x-database-env: &database-env POSTGRES_DB: "postgres" @@ -11,7 +9,7 @@ x-test-env: WAIT_HOSTS: "postgres_clear:6000,postgres_md5:6001,postgres_scram:6002" # Wait fifteen seconds after database goes online # for database metadata initialization - WAIT_AFTER_HOSTS: "15" + WAIT_AFTER: "15" x-test-volumes: &test-volumes @@ -81,3 +79,19 @@ services: <<: *test-env NO_COLOR: "true" volumes: *test-volumes + + doc_tests: + image: postgres/tests + command: sh -c "/wait && deno test -A --doc client.ts mod.ts pool.ts client/ connection/ query/ utils/" + depends_on: + - postgres_clear + - postgres_md5 + - postgres_scram + environment: + <<: *test-env + PGDATABASE: "postgres" + PGPASSWORD: "postgres" + PGUSER: "postgres" + PGHOST: "postgres_md5" + PGPORT: 6001 + volumes: *test-volumes diff --git a/docs/README.md b/docs/README.md index 528c2d25..97527885 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,17 +1,19 @@ # deno-postgres -![Build Status](https://img.shields.io/github/workflow/status/denodrivers/postgres/ci?label=Build&logo=github&style=flat-square) -[![Discord server](https://img.shields.io/discord/768918486575480863?color=blue&label=Ask%20for%20help%20here&logo=discord&style=flat-square)](https://discord.gg/HEdTCvZUSf) -![Manual](https://img.shields.io/github/v/release/denodrivers/postgres?color=orange&label=Manual&logo=deno&style=flat-square) -[![Documentation](https://img.shields.io/github/v/release/denodrivers/postgres?color=yellow&label=Documentation&logo=deno&style=flat-square)](https://doc.deno.land/https/deno.land/x/postgres/mod.ts) -![License](https://img.shields.io/github/license/denodrivers/postgres?color=yellowgreen&label=License&style=flat-square) +![Build Status](https://img.shields.io/github/actions/workflow/status/denodrivers/postgres/ci.yml?branch=main&label=Build&logo=github&style=flat-square) +[![Discord server](https://img.shields.io/discord/768918486575480863?color=blue&label=Ask%20for%20help%20here&logo=discord&style=flat-square)](https://discord.com/invite/HEdTCvZUSf) +[![JSR](https://jsr.io/badges/@db/postgres?style=flat-square)](https://jsr.io/@db/postgres) +[![JSR Score](https://jsr.io/badges/@db/postgres/score?style=flat-square)](https://jsr.io/@db/postgres) +[![Manual](https://img.shields.io/github/v/release/denodrivers/postgres?color=orange&label=Manual&logo=deno&style=flat-square)](https://deno-postgres.com) +[![Documentation](https://img.shields.io/github/v/release/denodrivers/postgres?color=yellow&label=Documentation&logo=deno&style=flat-square)](https://jsr.io/@db/postgres/doc) +[![License](https://img.shields.io/github/license/denodrivers/postgres?color=yellowgreen&label=License&style=flat-square)](LICENSE) `deno-postgres` is a lightweight PostgreSQL driver for Deno focused on user experience. It provides abstractions for most common operations such as typed queries, prepared statements, connection pools, and transactions. ```ts -import { Client } from "https://deno.land/x/postgres/mod.ts"; +import { Client } from "jsr:@db/postgres"; const client = new Client({ user: "user", @@ -38,7 +40,7 @@ All `deno-postgres` clients provide the following options to authenticate and manage your connections ```ts -import { Client } from "https://deno.land/x/postgres/mod.ts"; +import { Client } from "jsr:@db/postgres"; let config; @@ -114,7 +116,7 @@ of search parameters such as the following: - password: If password is not specified in the url, this will be taken instead - port: If port is not specified in the url, this will be taken instead - options: This parameter can be used by other database engines usable through - the Postgres protocol (such as Cockroachdb for example) to send additional + the Postgres protocol (such as CockroachDB for example) to send additional values for connection (ej: options=--cluster=your_cluster_name) - sslmode: Allows you to specify the tls configuration for your client; the allowed values are the following: @@ -231,9 +233,6 @@ instead of TCP by providing the route to the socket file your Postgres database creates automatically. You can manually set the protocol used with the `host_type` property in the client options -**Note**: This functionality is only available on UNIX systems under the -`--unstable` flag - In order to connect to the socket you can pass the path as a host in the client initialization. Alternatively, you can specify the port the database is listening on and the parent folder of the socket as a host (The equivalent of @@ -343,8 +342,8 @@ certificate to encrypt your connection that, if not taken care of, can render your certificate invalid. When using a self-signed certificate, make sure to specify the PEM encoded CA -certificate using the `--cert` option when starting Deno (Deno 1.12.2 or later) -or in the `tls.caCertificates` option when creating a client (Deno 1.15.0 later) +certificate using the `--cert` option when starting Deno or in the +`tls.caCertificates` option when creating a client ```ts const client = new Client({ @@ -381,7 +380,7 @@ https://www.postgresql.org/docs/14/libpq-envars.html) ```ts // PGUSER=user PGPASSWORD=admin PGDATABASE=test deno run --allow-net --allow-env database.js -import { Client } from "https://deno.land/x/postgres/mod.ts"; +import { Client } from "jsr:@db/postgres"; const client = new Client(); await client.connect(); @@ -450,9 +449,12 @@ const dbPool = new Pool( POOL_CONNECTIONS, ); -const client = await dbPool.connect(); // 19 connections are still available -await client.queryArray`UPDATE X SET Y = 'Z'`; -client.release(); // This connection is now available for use again +// Note the `using` keyword in block scope +{ + using client = await dbPool.connect(); + // 19 connections are still available + await client.queryArray`UPDATE X SET Y = 'Z'`; +} // This connection is now available for use again ``` The number of pools is up to you, but a pool of 20 is good for small @@ -515,9 +517,9 @@ await client_3.release(); #### Pools made simple -The following example is a simple abstraction over pools that allows you to -execute one query and release the used client after returning the result in a -single function call +Because of `using` keyword there is no need for manually releasing pool client. + +Legacy code like this ```ts async function runQuery(query: string) { @@ -532,7 +534,27 @@ async function runQuery(query: string) { } await runQuery("SELECT ID, NAME FROM USERS"); // [{id: 1, name: 'Carlos'}, {id: 2, name: 'John'}, ...] -await runQuery("SELECT ID, NAME FROM USERS WHERE ID = '1'"); // [{id: 1, name: 'Carlos'}, {id: 2, name: 'John'}, ...] +await runQuery("SELECT ID, NAME FROM USERS WHERE ID = '1'"); // [{id: 1, name: 'Carlos'}] +``` + +Can now be written simply as + +```ts +async function runQuery(query: string) { + using client = await pool.connect(); + return await client.queryObject(query); +} + +await runQuery("SELECT ID, NAME FROM USERS"); // [{id: 1, name: 'Carlos'}, {id: 2, name: 'John'}, ...] +await runQuery("SELECT ID, NAME FROM USERS WHERE ID = '1'"); // [{id: 1, name: 'Carlos'}] +``` + +But you can release pool client manually if you wish + +```ts +const client = await dbPool.connect(); // note the `const` instead of `using` keyword +await client.queryArray`UPDATE X SET Y = 'Z'`; +client.release(); // This connection is now available for use again ``` ## Executing queries @@ -758,10 +780,10 @@ available: You can also provide custom decoders to the client that will be used to decode the result data. This can be done by setting the `decoders` controls option in the client configuration. This option is a map object where the keys are the -type names or Oid numbers and the values are the custom decoder functions. +type names or OID numbers and the values are the custom decoder functions. You can use it with the decode strategy. Custom decoders take precedence over -the strategy and internal parsers. +the strategy and internal decoders. ```ts { @@ -785,7 +807,37 @@ the strategy and internal parsers. const result = await client.queryObject( "SELECT ID, NAME, IS_ACTIVE FROM PEOPLE", ); - console.log(result.rows[0]); // {id: '1', name: 'Javier', is_active: { value: false, type: "boolean"}} + console.log(result.rows[0]); + // {id: '1', name: 'Javier', is_active: { value: false, type: "boolean"}} +} +``` + +The driver takes care of parsing the related `array` OID types automatically. +For example, if a custom decoder is defined for the `int4` type, it will be +applied when parsing `int4[]` arrays. If needed, you can have separate custom +decoders for the array and non-array types by defining another custom decoders +for the array type itself. + +```ts +{ + const client = new Client({ + database: "some_db", + user: "some_user", + controls: { + decodeStrategy: "string", + decoders: { + // Custom decoder for int4 (OID 23 = int4) + // convert to int and multiply by 100 + 23: (value: string) => parseInt(value, 10) * 100, + }, + }, + }); + + const result = await client.queryObject( + "SELECT ARRAY[ 2, 2, 3, 1 ] AS scores, 8 final_score;", + ); + console.log(result.rows[0]); + // { scores: [ 200, 200, 300, 100 ], final_score: 800 } } ``` @@ -856,14 +908,14 @@ for that such as aliasing every query field that is done to the database, one easy built-in solution allows developers to transform the incoming query names into the casing of their preference without any extra steps -##### Camelcase +##### Camel case -To transform a query result into camelcase, you only need to provide the -`camelcase` option on your query call +To transform a query result into camel case, you only need to provide the +`camelCase` option on your query call ```ts const { rows: result } = await client.queryObject({ - camelcase: true, + camelCase: true, text: "SELECT FIELD_X, FIELD_Y FROM MY_TABLE", }); @@ -1074,16 +1126,16 @@ const transaction = client.createTransaction("abortable"); await transaction.begin(); let savepoint; -try{ +try { // Oops, savepoints can't start with a number // Validation error, transaction won't be ended savepoint = await transaction.savepoint("1"); -}catch(e){ +} catch (e) { // We validate the error was not related to transaction execution - if(!(e instance of TransactionError)){ + if (!(e instanceof TransactionError)) { // We create a good savepoint we can use savepoint = await transaction.savepoint("a_valid_name"); - }else{ + } else { throw e; } } @@ -1393,3 +1445,61 @@ await transaction.queryArray`INSERT INTO DONT_DELETE_ME VALUES (2)`; // Still in await transaction.commit(); // Transaction ends, client gets unlocked ``` + +## Debugging + +The driver can provide different types of logs if as needed. By default, logs +are disabled to keep your environment as uncluttered as possible. Logging can be +enabled by using the `debug` option in the Client `controls` parameter. Pass +`true` to enable all logs, or turn on logs granularity by enabling the following +options: + +- `queries` : Logs all SQL queries executed by the client +- `notices` : Logs all database messages (INFO, NOTICE, WARNING)) +- `results` : Logs all the result of the queries +- `queryInError` : Includes the SQL query that caused an error in the + PostgresError object + +### Example + +```ts +// debug_test.ts +import { Client } from "jsr:@db/postgres"; + +const client = new Client({ + user: "postgres", + database: "postgres", + hostname: "localhost", + port: 5432, + password: "postgres", + controls: { + debug: { + queries: true, + notices: true, + results: true, + }, + }, +}); + +await client.connect(); + +await client.queryObject`SELECT public.get_uuid()`; + +await client.end(); +``` + +```sql +-- example database function that raises messages +CREATE OR REPLACE FUNCTION public.get_uuid() + RETURNS uuid LANGUAGE plpgsql +AS $function$ + BEGIN + RAISE INFO 'This function generates a random UUID :)'; + RAISE NOTICE 'A UUID takes up 128 bits in memory.'; + RAISE WARNING 'UUIDs must follow a specific format and length in order to be valid!'; + RETURN gen_random_uuid(); + END; +$function$;; +``` + +![debug-output](debug-output.png) diff --git a/docs/debug-output.png b/docs/debug-output.png new file mode 100644 index 00000000..02277a8d Binary files /dev/null and b/docs/debug-output.png differ diff --git a/docs/deno-postgres.png b/docs/deno-postgres.png new file mode 100644 index 00000000..3c1e735d Binary files /dev/null and b/docs/deno-postgres.png differ diff --git a/docs/index.html b/docs/index.html index 4ce33e9f..2fc96d36 100644 --- a/docs/index.html +++ b/docs/index.html @@ -1,22 +1,31 @@ - - - Deno Postgres - - - - - - -
- - - - + + + Deno Postgres + + + + + + +
+ + + + diff --git a/mod.ts b/mod.ts index 143abffc..13499468 100644 --- a/mod.ts +++ b/mod.ts @@ -5,12 +5,7 @@ export { TransactionError, } from "./client/error.ts"; export { Pool } from "./pool.ts"; -export { Oid, OidTypes } from "./query/oid.ts"; - -// TODO -// Remove the following reexports after https://doc.deno.land -// supports two level depth exports -export type { OidKey, OidType } from "./query/oid.ts"; +export { Oid, type OidType, OidTypes, type OidValue } from "./query/oid.ts"; export type { ClientOptions, ConnectionOptions, diff --git a/pool.ts b/pool.ts index ae2b58e6..16713d53 100644 --- a/pool.ts +++ b/pool.ts @@ -14,18 +14,19 @@ import { DeferredAccessStack } from "./utils/deferred.ts"; * with their PostgreSQL database * * ```ts - * import { Pool } from "https://deno.land/x/postgres/mod.ts"; + * import { Pool } from "jsr:@db/postgres"; * const pool = new Pool({ - * database: "database", - * hostname: "hostname", - * password: "password", - * port: 5432, - * user: "user", + * database: Deno.env.get("PGDATABASE"), + * hostname: Deno.env.get("PGHOST"), + * password: Deno.env.get("PGPASSWORD"), + * port: Deno.env.get("PGPORT"), + * user: Deno.env.get("PGUSER"), * }, 10); // Creates a pool with 10 available connections * * const client = await pool.connect(); * await client.queryArray`SELECT 1`; * client.release(); + * await pool.end(); * ``` * * You can also opt to not initialize all your connections at once by passing the `lazy` @@ -34,7 +35,7 @@ import { DeferredAccessStack } from "./utils/deferred.ts"; * available connections in the pool * * ```ts - * import { Pool } from "https://deno.land/x/postgres/mod.ts"; + * import { Pool } from "jsr:@db/postgres"; * // Creates a pool with 10 max available connections * // Connection with the database won't be established until the user requires it * const pool = new Pool({}, 10, true); @@ -53,6 +54,7 @@ import { DeferredAccessStack } from "./utils/deferred.ts"; * const client_3 = await pool.connect(); * client_2.release(); * client_3.release(); + * await pool.end(); * ``` */ export class Pool { @@ -117,11 +119,12 @@ export class Pool { * with the database if no other connections are available * * ```ts - * import { Pool } from "https://deno.land/x/postgres/mod.ts"; + * import { Pool } from "jsr:@db/postgres"; * const pool = new Pool({}, 10); * const client = await pool.connect(); - * await client.queryArray`UPDATE MY_TABLE SET X = 1`; + * await client.queryArray`SELECT * FROM CLIENTS`; * client.release(); + * await pool.end(); * ``` */ async connect(): Promise { @@ -138,24 +141,29 @@ export class Pool { * This will close all open connections and set a terminated status in the pool * * ```ts - * import { Pool } from "https://deno.land/x/postgres/mod.ts"; + * import { Pool } from "jsr:@db/postgres"; * const pool = new Pool({}, 10); * * await pool.end(); * console.assert(pool.available === 0, "There are connections available after ending the pool"); - * await pool.end(); // An exception will be thrown, pool doesn't have any connections to close + * try { + * await pool.end(); // An exception will be thrown, pool doesn't have any connections to close + * } catch (e) { + * console.log(e); + * } * ``` * * However, a terminated pool can be reused by using the "connect" method, which * will reinitialize the connections according to the original configuration of the pool * * ```ts - * import { Pool } from "https://deno.land/x/postgres/mod.ts"; + * import { Pool } from "jsr:@db/postgres"; * const pool = new Pool({}, 10); * await pool.end(); * const client = await pool.connect(); * await client.queryArray`SELECT 1`; // Works! * client.release(); + * await pool.end(); * ``` */ async end(): Promise { diff --git a/query/array_parser.ts b/query/array_parser.ts index 9fd043bd..8ca9175f 100644 --- a/query/array_parser.ts +++ b/query/array_parser.ts @@ -6,11 +6,21 @@ type AllowedSeparators = "," | ";"; type ArrayResult = Array>; type Transformer = (value: string) => T; +export type ParseArrayFunction = typeof parseArray; + +/** + * Parse a string into an array of values using the provided transform function. + * + * @param source The string to parse + * @param transform A function to transform each value in the array + * @param separator The separator used to split the string into values + * @returns + */ export function parseArray( source: string, transform: Transformer, separator: AllowedSeparators = ",", -) { +): ArrayResult { return new ArrayParser(source, transform, separator).parse(); } @@ -79,7 +89,7 @@ class ArrayParser { this.dimension++; if (this.dimension > 1) { parser = new ArrayParser( - this.source.substr(this.position - 1), + this.source.substring(this.position - 1), this.transform, this.separator, ); diff --git a/query/decode.ts b/query/decode.ts index 2904567d..c0311910 100644 --- a/query/decode.ts +++ b/query/decode.ts @@ -1,5 +1,5 @@ -import { Oid, OidType, OidTypes } from "./oid.ts"; -import { bold, yellow } from "../deps.ts"; +import { Oid, type OidType, OidTypes, type OidValue } from "./oid.ts"; +import { bold, yellow } from "@std/fmt/colors"; import { decodeBigint, decodeBigintArray, @@ -35,7 +35,8 @@ import { decodeTid, decodeTidArray, } from "./decoders.ts"; -import { ClientControls } from "../connection/connection_params.ts"; +import type { ClientControls } from "../connection/connection_params.ts"; +import { parseArray } from "./array_parser.ts"; export class Column { constructor( @@ -195,10 +196,10 @@ function decodeText(value: string, typeOid: number) { // them as they see fit return value; } - } catch (_e) { + } catch (e) { console.error( bold(yellow(`Error decoding type Oid ${typeOid} value`)) + - _e.message + + (e instanceof Error ? e.message : e) + "\n" + bold("Defaulting to null."), ); @@ -216,12 +217,29 @@ export function decode( // check if there is a custom decoder if (controls?.decoders) { + const oidType = OidTypes[column.typeOid as OidValue]; // check if there is a custom decoder by oid (number) or by type name (string) const decoderFunc = controls.decoders?.[column.typeOid] || - controls.decoders?.[OidTypes[column.typeOid as OidType]]; + controls.decoders?.[oidType]; if (decoderFunc) { - return decoderFunc(strValue, column.typeOid); + return decoderFunc(strValue, column.typeOid, parseArray); + } // if no custom decoder is found and the oid is for an array type, check if there is + // a decoder for the base type and use that with the array parser + else if (oidType?.includes("_array")) { + const baseOidType = oidType.replace("_array", "") as OidType; + // check if the base type is in the Oid object + if (baseOidType in Oid) { + // check if there is a custom decoder for the base type by oid (number) or by type name (string) + const decoderFunc = controls.decoders?.[Oid[baseOidType]] || + controls.decoders?.[baseOidType]; + if (decoderFunc) { + return parseArray( + strValue, + (value: string) => decoderFunc(value, column.typeOid, parseArray), + ); + } + } } } diff --git a/query/decoders.ts b/query/decoders.ts index 4edbb03a..58356d76 100644 --- a/query/decoders.ts +++ b/query/decoders.ts @@ -1,4 +1,3 @@ -import { parseDate } from "../deps.ts"; import { parseArray } from "./array_parser.ts"; import type { Box, @@ -64,7 +63,9 @@ export function decodeBox(value: string): Box { b: decodePoint(b), }; } catch (e) { - throw new Error(`Invalid Box: "${value}" : ${e.message}`); + throw new Error( + `Invalid Box: "${value}" : ${(e instanceof Error ? e.message : e)}`, + ); } } @@ -93,8 +94,8 @@ function decodeByteaEscape(byteaStr: string): Uint8Array { bytes.push(byteaStr.charCodeAt(i)); ++i; } else { - if (/[0-7]{3}/.test(byteaStr.substr(i + 1, 3))) { - bytes.push(parseInt(byteaStr.substr(i + 1, 3), 8)); + if (/[0-7]{3}/.test(byteaStr.substring(i + 1, i + 4))) { + bytes.push(parseInt(byteaStr.substring(i + 1, i + 4), 8)); i += 4; } else { let backslashes = 1; @@ -140,7 +141,9 @@ export function decodeCircle(value: string): Circle { radius: radius, }; } catch (e) { - throw new Error(`Invalid Circle: "${value}" : ${e.message}`); + throw new Error( + `Invalid Circle: "${value}" : ${(e instanceof Error ? e.message : e)}`, + ); } } @@ -157,7 +160,7 @@ export function decodeDate(dateStr: string): Date | number { return Number(-Infinity); } - return parseDate(dateStr, "yyyy-MM-dd"); + return new Date(dateStr); } export function decodeDateArray(value: string) { @@ -249,13 +252,13 @@ export function decodeLine(value: string): Line { ); } - equationConsts.forEach((c) => { + for (const c of equationConsts) { if (Number.isNaN(parseFloat(c))) { throw new Error( `Invalid Line: "${value}". Line constant "${c}" must be a valid number.`, ); } - }); + } const [a, b, c] = equationConsts; @@ -287,7 +290,11 @@ export function decodeLineSegment(value: string): LineSegment { b: decodePoint(b), }; } catch (e) { - throw new Error(`Invalid Line Segment: "${value}" : ${e.message}`); + throw new Error( + `Invalid Line Segment: "${value}" : ${(e instanceof Error + ? e.message + : e)}`, + ); } } @@ -304,7 +311,9 @@ export function decodePath(value: string): Path { try { return decodePoint(point); } catch (e) { - throw new Error(`Invalid Path: "${value}" : ${e.message}`); + throw new Error( + `Invalid Path: "${value}" : ${(e instanceof Error ? e.message : e)}`, + ); } }); } @@ -348,7 +357,9 @@ export function decodePolygon(value: string): Polygon { try { return decodePath(value); } catch (e) { - throw new Error(`Invalid Polygon: "${value}" : ${e.message}`); + throw new Error( + `Invalid Polygon: "${value}" : ${(e instanceof Error ? e.message : e)}`, + ); } } diff --git a/query/encode.ts b/query/encode.ts index 36407bf2..94cf2b60 100644 --- a/query/encode.ts +++ b/query/encode.ts @@ -50,24 +50,23 @@ function escapeArrayElement(value: unknown): string { function encodeArray(array: Array): string { let encodedArray = "{"; - array.forEach((element, index) => { + for (let index = 0; index < array.length; index++) { if (index > 0) { encodedArray += ","; } + const element = array[index]; if (element === null || typeof element === "undefined") { encodedArray += "NULL"; } else if (Array.isArray(element)) { encodedArray += encodeArray(element); } else if (element instanceof Uint8Array) { - // TODO - // Should it be encoded as bytea? - throw new Error("Can't encode array of buffers."); + encodedArray += encodeBytes(element); } else { const encodedElement = encodeArgument(element); encodedArray += escapeArrayElement(encodedElement as string); } - }); + } encodedArray += "}"; return encodedArray; @@ -91,15 +90,18 @@ export type EncodedArg = null | string | Uint8Array; export function encodeArgument(value: unknown): EncodedArg { if (value === null || typeof value === "undefined") { return null; - } else if (value instanceof Uint8Array) { + } + if (value instanceof Uint8Array) { return encodeBytes(value); - } else if (value instanceof Date) { + } + if (value instanceof Date) { return encodeDate(value); - } else if (value instanceof Array) { + } + if (value instanceof Array) { return encodeArray(value); - } else if (value instanceof Object) { + } + if (value instanceof Object) { return JSON.stringify(value); - } else { - return String(value); } + return String(value); } diff --git a/query/oid.ts b/query/oid.ts index 9b36c88b..93c03ec2 100644 --- a/query/oid.ts +++ b/query/oid.ts @@ -1,8 +1,10 @@ -export type OidKey = keyof typeof Oid; -export type OidType = (typeof Oid)[OidKey]; +/** A Postgres Object identifiers (OIDs) type name. */ +export type OidType = keyof typeof Oid; +/** A Postgres Object identifiers (OIDs) numeric value. */ +export type OidValue = (typeof Oid)[OidType]; /** - * Oid is a map of OidKey to OidType. + * A map of OidType to OidValue. */ export const Oid = { bool: 16, @@ -175,11 +177,10 @@ export const Oid = { } as const; /** - * OidTypes is a map of OidType to OidKey. - * Used to decode values and avoid search iteration + * A map of OidValue to OidType. Used to decode values and avoid search iteration. */ export const OidTypes: { - [key in OidType]: OidKey; + [key in OidValue]: OidType; } = { 16: "bool", 17: "bytea", diff --git a/query/query.ts b/query/query.ts index 0bb39d7b..bdf0276e 100644 --- a/query/query.ts +++ b/query/query.ts @@ -1,7 +1,7 @@ import { encodeArgument, type EncodedArg } from "./encode.ts"; import { type Column, decode } from "./decode.ts"; -import { type Notice } from "../connection/message.ts"; -import { type ClientControls } from "../connection/connection_params.ts"; +import type { Notice } from "../connection/message.ts"; +import type { ClientControls } from "../connection/connection_params.ts"; // TODO // Limit the type of parameters that can be passed @@ -15,13 +15,14 @@ import { type ClientControls } from "../connection/connection_params.ts"; * They will take the position according to the order in which they were provided * * ```ts - * import { Client } from "https://deno.land/x/postgres/mod.ts"; + * import { Client } from "jsr:@db/postgres"; * const my_client = new Client(); * - * await my_client.queryArray("SELECT ID, NAME FROM PEOPLE WHERE AGE > $1 AND AGE < $2", [ - * 10, // $1 - * 20, // $2 + * await my_client.queryArray("SELECT ID, NAME FROM CLIENTS WHERE NAME = $1", [ + * "John", // $1 * ]); + * + * await my_client.end(); * ``` */ @@ -38,7 +39,8 @@ export type CommandType = | "SELECT" | "MOVE" | "FETCH" - | "COPY"; + | "COPY" + | "CREATE"; /** Type of a query result */ export enum ResultType { @@ -132,19 +134,19 @@ export interface QueryObjectOptions extends QueryOptions { // TODO // Support multiple case options /** - * Enabling camelcase will transform any snake case field names coming from the database into camel case ones + * Enabling camel case will transform any snake case field names coming from the database into camel case ones * * Ex: `SELECT 1 AS my_field` will return `{ myField: 1 }` * * This won't have any effect if you explicitly set the field names with the `fields` parameter */ - camelcase?: boolean; + camelCase?: boolean; /** * This parameter supersedes query column names coming from the databases in the order they were provided. * Fields must be unique and be in the range of (a-zA-Z0-9_), otherwise the query will throw before execution. * A field can not start with a number, just like JavaScript variables * - * This setting overrides the camelcase option + * This setting overrides the camel case option * * Ex: `SELECT 'A', 'B' AS my_field` with fields `["field_1", "field_2"]` will return `{ field_1: "A", field_2: "B" }` */ @@ -154,7 +156,7 @@ export interface QueryObjectOptions extends QueryOptions { /** * This class is used to handle the result of a query */ -export class QueryResult { +export abstract class QueryResult { /** * Type of query executed for this result */ @@ -224,9 +226,7 @@ export class QueryResult { * * This function can throw on validation, so any errors must be handled in the message loop accordingly */ - insertRow(_row: Uint8Array[]): void { - throw new Error("No implementation for insertRow is defined"); - } + abstract insertRow(_row: Uint8Array[]): void; } /** @@ -324,7 +324,7 @@ export class QueryObjectResult< this.columns = this.query.fields; } else { let column_names: string[]; - if (this.query.camelcase) { + if (this.query.camelCase) { column_names = this.rowDescription.columns.map((column) => snakecaseToCamelcase(column.name) ); @@ -380,7 +380,7 @@ export class QueryObjectResult< */ export class Query { public args: EncodedArg[]; - public camelcase?: boolean; + public camelCase?: boolean; /** * The explicitly set fields for the query result, they have been validated beforehand * for duplicates and invalid names @@ -408,7 +408,7 @@ export class Query { this.text = config_or_text; this.args = args.map(encodeArgument); } else { - const { camelcase, encoder = encodeArgument, fields } = config_or_text; + const { camelCase, encoder = encodeArgument, fields } = config_or_text; let { args = [], text } = config_or_text; // Check that the fields passed are valid and can be used to map @@ -432,7 +432,7 @@ export class Query { this.fields = fields; } - this.camelcase = camelcase; + this.camelCase = camelCase; if (!Array.isArray(args)) { [text, args] = objectQueryToQueryArgs(text, args); diff --git a/query/transaction.ts b/query/transaction.ts index 3dadd33a..2b8dd6ea 100644 --- a/query/transaction.ts +++ b/query/transaction.ts @@ -1,4 +1,4 @@ -import { type QueryClient } from "../client.ts"; +import type { QueryClient } from "../client.ts"; import { Query, type QueryArguments, @@ -60,26 +60,36 @@ export class Savepoint { * Releasing a savepoint will remove it's last instance in the transaction * * ```ts - * import { Client } from "https://deno.land/x/postgres/mod.ts"; + * import { Client } from "jsr:@db/postgres"; * const client = new Client(); * const transaction = client.createTransaction("transaction"); * + * await transaction.begin(); * const savepoint = await transaction.savepoint("n1"); * await savepoint.release(); - * transaction.rollback(savepoint); // Error, can't rollback because the savepoint was released + * + * try { + * await transaction.rollback(savepoint); // Error, can't rollback because the savepoint was released + * } catch (e) { + * console.log(e); + * } + * + * await client.end(); * ``` * * It will also allow you to set the savepoint to the position it had before the last update * * ```ts - * import { Client } from "https://deno.land/x/postgres/mod.ts"; + * import { Client } from "jsr:@db/postgres"; * const client = new Client(); - * const transaction = client.createTransaction("transaction"); + * const transaction = client.createTransaction("transaction1"); * + * await transaction.begin(); * const savepoint = await transaction.savepoint("n1"); * await savepoint.update(); * await savepoint.release(); // This drops the update of the last statement - * transaction.rollback(savepoint); // Will rollback to the first instance of the savepoint + * await transaction.rollback(savepoint); // Will rollback to the first instance of the savepoint + * await client.end(); * ``` * * This function will throw if there are no savepoint instances to drop @@ -97,29 +107,33 @@ export class Savepoint { * Updating a savepoint will update its position in the transaction execution * * ```ts - * import { Client } from "https://deno.land/x/postgres/mod.ts"; + * import { Client } from "jsr:@db/postgres"; * const client = new Client(); - * const transaction = client.createTransaction("transaction"); + * const transaction = client.createTransaction("transaction1"); * - * const my_value = "some value"; + * await transaction.begin(); * * const savepoint = await transaction.savepoint("n1"); - * transaction.queryArray`INSERT INTO MY_TABLE (X) VALUES (${my_value})`; + * transaction.queryArray`DELETE FROM CLIENTS`; * await savepoint.update(); // Rolling back will now return you to this point on the transaction + * await client.end(); * ``` * * You can also undo a savepoint update by using the `release` method * * ```ts - * import { Client } from "https://deno.land/x/postgres/mod.ts"; + * import { Client } from "jsr:@db/postgres"; * const client = new Client(); - * const transaction = client.createTransaction("transaction"); + * const transaction = client.createTransaction("transaction1"); + * + * await transaction.begin(); * * const savepoint = await transaction.savepoint("n1"); - * transaction.queryArray`DELETE FROM VERY_IMPORTANT_TABLE`; + * transaction.queryArray`DELETE FROM CLIENTS`; * await savepoint.update(); // Oops, shouldn't have updated the savepoint * await savepoint.release(); // This will undo the last update and return the savepoint to the first instance * await transaction.rollback(); // Will rollback before the table was deleted + * await client.end(); * ``` */ async update() { @@ -197,13 +211,14 @@ export class Transaction { * The begin method will officially begin the transaction, and it must be called before * any query or transaction operation is executed in order to lock the session * ```ts - * import { Client } from "https://deno.land/x/postgres/mod.ts"; + * import { Client } from "jsr:@db/postgres"; * const client = new Client(); * const transaction = client.createTransaction("transaction_name"); * * await transaction.begin(); // Session is locked, transaction operations are now safe * // Important operations * await transaction.commit(); // Session is unlocked, external operations can now take place + * await client.end(); * ``` * https://www.postgresql.org/docs/14/sql-begin.html */ @@ -257,9 +272,8 @@ export class Transaction { } catch (e) { if (e instanceof PostgresError) { throw new TransactionError(this.name, e); - } else { - throw e; } + throw e; } this.#updateClientLock(this.name); @@ -273,27 +287,31 @@ export class Transaction { * current transaction and end the current transaction * * ```ts - * import { Client } from "https://deno.land/x/postgres/mod.ts"; + * import { Client } from "jsr:@db/postgres"; * const client = new Client(); * const transaction = client.createTransaction("transaction"); * * await transaction.begin(); * // Important operations * await transaction.commit(); // Will terminate the transaction and save all changes + * await client.end(); * ``` * * The commit method allows you to specify a "chain" option, that allows you to both commit the current changes and * start a new with the same transaction parameters in a single statement * * ```ts - * import { Client } from "https://deno.land/x/postgres/mod.ts"; + * import { Client } from "jsr:@db/postgres"; * const client = new Client(); - * const transaction = client.createTransaction("transaction"); + * const transaction = client.createTransaction("transaction1"); + * + * await transaction.begin(); * * // Transaction operations I want to commit * await transaction.commit({ chain: true }); // All changes are saved, following statements will be executed inside a transaction - * await transaction.queryArray`DELETE SOMETHING FROM SOMEWHERE`; // Still inside the transaction + * await transaction.queryArray`DELETE FROM CLIENTS`; // Still inside the transaction * await transaction.commit(); // The transaction finishes for good + * await client.end(); * ``` * * https://www.postgresql.org/docs/14/sql-commit.html @@ -312,9 +330,8 @@ export class Transaction { } catch (e) { if (e instanceof PostgresError) { throw new TransactionError(this.name, e); - } else { - throw e; } + throw e; } } @@ -346,14 +363,19 @@ export class Transaction { * the snapshot state between two transactions * * ```ts - * import { Client } from "https://deno.land/x/postgres/mod.ts"; + * import { Client } from "jsr:@db/postgres"; * const client_1 = new Client(); * const client_2 = new Client(); * const transaction_1 = client_1.createTransaction("transaction"); * + * await transaction_1.begin(); + * * const snapshot = await transaction_1.getSnapshot(); * const transaction_2 = client_2.createTransaction("new_transaction", { isolation_level: "repeatable_read", snapshot }); * // transaction_2 now shares the same starting state that transaction_1 had + * + * await client_1.end(); + * await client_2.end(); * ``` * https://www.postgresql.org/docs/14/functions-admin.html#FUNCTIONS-SNAPSHOT-SYNCHRONIZATION */ @@ -371,36 +393,48 @@ export class Transaction { * It supports a generic interface in order to type the entries retrieved by the query * * ```ts - * import { Client } from "https://deno.land/x/postgres/mod.ts"; + * import { Client } from "jsr:@db/postgres"; * const client = new Client(); * const transaction = client.createTransaction("transaction"); * + * await transaction.begin(); + * * const {rows} = await transaction.queryArray( * "SELECT ID, NAME FROM CLIENTS" * ); // Array + * + * await client.end(); * ``` * * You can pass type arguments to the query in order to hint TypeScript what the return value will be * ```ts - * import { Client } from "https://deno.land/x/postgres/mod.ts"; + * import { Client } from "jsr:@db/postgres"; * const client = new Client(); * const transaction = client.createTransaction("transaction"); * + * await transaction.begin(); + * * const { rows } = await transaction.queryArray<[number, string]>( * "SELECT ID, NAME FROM CLIENTS" * ); // Array<[number, string]> + * + * await client.end(); * ``` * * It also allows you to execute prepared stamements with template strings * * ```ts - * import { Client } from "https://deno.land/x/postgres/mod.ts"; + * import { Client } from "jsr:@db/postgres"; * const client = new Client(); * const transaction = client.createTransaction("transaction"); * + * await transaction.begin(); + * * const id = 12; * // Array<[number, string]> * const { rows } = await transaction.queryArray<[number, string]>`SELECT ID, NAME FROM CLIENTS WHERE ID = ${id}`; + * + * await client.end(); * ``` */ async queryArray>( @@ -411,12 +445,13 @@ export class Transaction { * Use the configuration object for more advance options to execute the query * * ```ts - * import { Client } from "https://deno.land/x/postgres/mod.ts"; + * import { Client } from "jsr:@db/postgres"; * const my_client = new Client(); * const { rows } = await my_client.queryArray<[number, string]>({ * text: "SELECT ID, NAME FROM CLIENTS", * name: "select_clients", * }); // Array<[number, string]> + * await my_client.end(); * ``` */ async queryArray>( @@ -426,12 +461,14 @@ export class Transaction { * Execute prepared statements with template strings * * ```ts - * import { Client } from "https://deno.land/x/postgres/mod.ts"; + * import { Client } from "jsr:@db/postgres"; * const my_client = new Client(); * * const id = 12; * // Array<[number, string]> * const {rows} = await my_client.queryArray<[number, string]>`SELECT ID, NAME FROM CLIENTS WHERE ID = ${id}`; + * + * await my_client.end(); * ``` */ async queryArray>( @@ -467,9 +504,8 @@ export class Transaction { if (e instanceof PostgresError) { await this.commit(); throw new TransactionError(this.name, e); - } else { - throw e; } + throw e; } } @@ -477,7 +513,7 @@ export class Transaction { * Executed queries and retrieve the data as object entries. It supports a generic in order to type the entries retrieved by the query * * ```ts - * import { Client } from "https://deno.land/x/postgres/mod.ts"; + * import { Client } from "jsr:@db/postgres"; * const my_client = new Client(); * * const { rows: rows1 } = await my_client.queryObject( @@ -487,6 +523,8 @@ export class Transaction { * const { rows: rows2 } = await my_client.queryObject<{id: number, name: string}>( * "SELECT ID, NAME FROM CLIENTS" * ); // Array<{id: number, name: string}> + * + * await my_client.end(); * ``` */ async queryObject( @@ -497,7 +535,7 @@ export class Transaction { * Use the configuration object for more advance options to execute the query * * ```ts - * import { Client } from "https://deno.land/x/postgres/mod.ts"; + * import { Client } from "jsr:@db/postgres"; * const my_client = new Client(); * * const { rows: rows1 } = await my_client.queryObject( @@ -510,6 +548,8 @@ export class Transaction { * fields: ["personal_id", "complete_name"], * }); * console.log(rows2); // [{personal_id: 78, complete_name: "Frank"}, {personal_id: 15, complete_name: "Sarah"}] + * + * await my_client.end(); * ``` */ async queryObject( @@ -519,11 +559,12 @@ export class Transaction { * Execute prepared statements with template strings * * ```ts - * import { Client } from "https://deno.land/x/postgres/mod.ts"; + * import { Client } from "jsr:@db/postgres"; * const my_client = new Client(); * const id = 12; * // Array<{id: number, name: string}> * const { rows } = await my_client.queryObject<{id: number, name: string}>`SELECT ID, NAME FROM CLIENTS WHERE ID = ${id}`; + * await my_client.end(); * ``` */ async queryObject( @@ -565,9 +606,8 @@ export class Transaction { if (e instanceof PostgresError) { await this.commit(); throw new TransactionError(this.name, e); - } else { - throw e; } + throw e; } } @@ -578,12 +618,15 @@ export class Transaction { * Calling a rollback without arguments will terminate the current transaction and undo all changes. * * ```ts - * import { Client } from "https://deno.land/x/postgres/mod.ts"; + * import { Client } from "jsr:@db/postgres"; * const client = new Client(); * const transaction = client.createTransaction("transaction"); * + * await transaction.begin(); + * * // Very very important operations that went very, very wrong * await transaction.rollback(); // Like nothing ever happened + * await client.end(); * ``` * * https://www.postgresql.org/docs/14/sql-rollback.html @@ -593,13 +636,15 @@ export class Transaction { * Savepoints can be used to rollback specific changes part of a transaction. * * ```ts - * import { Client } from "https://deno.land/x/postgres/mod.ts"; + * import { Client } from "jsr:@db/postgres"; * const client = new Client(); - * const transaction = client.createTransaction("transaction"); + * const transaction = client.createTransaction("transaction1"); + * + * await transaction.begin(); * * // Important operations I don't want to rollback * const savepoint = await transaction.savepoint("before_disaster"); - * await transaction.queryArray`UPDATE MY_TABLE SET X = 0`; // Oops, update without where + * await transaction.queryArray`DELETE FROM CLIENTS`; // Oops, deleted the wrong thing * * // These are all the same, everything that happened between the savepoint and the rollback gets undone * await transaction.rollback(savepoint); @@ -607,6 +652,7 @@ export class Transaction { * await transaction.rollback({ savepoint: 'before_disaster'}) * * await transaction.commit(); // Commits all other changes + * await client.end(); * ``` */ async rollback( @@ -616,14 +662,17 @@ export class Transaction { * The `chain` option allows you to undo the current transaction and restart it with the same parameters in a single statement * * ```ts - * import { Client } from "https://deno.land/x/postgres/mod.ts"; + * import { Client } from "jsr:@db/postgres"; * const client = new Client(); - * const transaction = client.createTransaction("transaction"); + * const transaction = client.createTransaction("transaction2"); + * + * await transaction.begin(); * * // Transaction operations I want to undo * await transaction.rollback({ chain: true }); // All changes are undone, but the following statements will be executed inside a transaction as well - * await transaction.queryArray`DELETE SOMETHING FROM SOMEWHERE`; // Still inside the transaction + * await transaction.queryArray`DELETE FROM CLIENTS`; // Still inside the transaction * await transaction.commit(); // The transaction finishes for good + * await client.end(); * ``` */ async rollback(options?: { chain?: boolean }): Promise; @@ -701,9 +750,8 @@ export class Transaction { if (e instanceof PostgresError) { await this.commit(); throw new TransactionError(this.name, e); - } else { - throw e; } + throw e; } this.#resetTransaction(); @@ -725,42 +773,51 @@ export class Transaction { * * A savepoint can be easily created like this * ```ts - * import { Client } from "https://deno.land/x/postgres/mod.ts"; + * import { Client } from "jsr:@db/postgres"; * const client = new Client(); * const transaction = client.createTransaction("transaction"); * + * await transaction.begin(); + * * const savepoint = await transaction.savepoint("MY_savepoint"); // returns a `Savepoint` with name "my_savepoint" * await transaction.rollback(savepoint); * await savepoint.release(); // The savepoint will be removed + * await client.end(); * ``` * All savepoints can have multiple positions in a transaction, and you can change or update * this positions by using the `update` and `release` methods * ```ts - * import { Client } from "https://deno.land/x/postgres/mod.ts"; + * import { Client } from "jsr:@db/postgres"; * const client = new Client(); - * const transaction = client.createTransaction("transaction"); + * const transaction = client.createTransaction("transaction1"); + * + * await transaction.begin(); * * const savepoint = await transaction.savepoint("n1"); - * await transaction.queryArray`INSERT INTO MY_TABLE VALUES (${'A'}, ${2})`; + * await transaction.queryArray`DELETE FROM CLIENTS`; * await savepoint.update(); // The savepoint will continue from here - * await transaction.queryArray`DELETE FROM MY_TABLE`; - * await transaction.rollback(savepoint); // The transaction will rollback before the delete, but after the insert + * await transaction.queryArray`DELETE FROM CLIENTS`; + * await transaction.rollback(savepoint); // The transaction will rollback before the secpmd delete * await savepoint.release(); // The last savepoint will be removed, the original one will remain - * await transaction.rollback(savepoint); // It rolls back before the insert + * await transaction.rollback(savepoint); // It rolls back before the delete * await savepoint.release(); // All savepoints are released + * await client.end(); * ``` * * Creating a new savepoint with an already used name will return you a reference to * the original savepoint * ```ts - * import { Client } from "https://deno.land/x/postgres/mod.ts"; + * import { Client } from "jsr:@db/postgres"; * const client = new Client(); - * const transaction = client.createTransaction("transaction"); + * const transaction = client.createTransaction("transaction2"); + * + * await transaction.begin(); * * const savepoint_a = await transaction.savepoint("a"); - * await transaction.queryArray`DELETE FROM MY_TABLE`; + * await transaction.queryArray`DELETE FROM CLIENTS`; * const savepoint_b = await transaction.savepoint("a"); // They will be the same savepoint, but the savepoint will be updated to this position * await transaction.rollback(savepoint_a); // Rolls back to savepoint_b + * await client.end(); * ``` * https://www.postgresql.org/docs/14/sql-savepoint.html */ @@ -792,9 +849,8 @@ export class Transaction { if (e instanceof PostgresError) { await this.commit(); throw new TransactionError(this.name, e); - } else { - throw e; } + throw e; } } else { savepoint = new Savepoint( @@ -813,9 +869,8 @@ export class Transaction { if (e instanceof PostgresError) { await this.commit(); throw new TransactionError(this.name, e); - } else { - throw e; } + throw e; } this.#savepoints.push(savepoint); } diff --git a/tests/README.md b/tests/README.md index c17f1a58..38cc8c41 100644 --- a/tests/README.md +++ b/tests/README.md @@ -1,9 +1,11 @@ # Testing -To run tests, first prepare your configuration file by copying +To run tests, we recommend using Docker. With Docker, there is no need to modify +any configuration, just run the build and test commands. + +If running tests on your host, prepare your configuration file by copying `config.example.json` into `config.json` and updating it appropriately based on -your environment. If you use the Docker based configuration below there's no -need to modify the configuration. +your environment. ## Running the Tests @@ -14,8 +16,8 @@ From within the project directory, run: deno test --allow-read --allow-net --allow-env # run in docker container -docker-compose build --no-cache -docker-compose run tests +docker compose build --no-cache +docker compose run tests ``` ## Docker Configuration @@ -23,7 +25,7 @@ docker-compose run tests If you have Docker installed then you can run the following to set up a running container that is compatible with the tests: -``` +```sh docker run --rm --env POSTGRES_USER=test --env POSTGRES_PASSWORD=test \ --env POSTGRES_DB=deno_postgres -p 5432:5432 postgres:12-alpine ``` diff --git a/tests/auth_test.ts b/tests/auth_test.ts index f7ed38db..4b06120e 100644 --- a/tests/auth_test.ts +++ b/tests/auth_test.ts @@ -1,4 +1,8 @@ -import { assertEquals, assertNotEquals, assertRejects } from "./test_deps.ts"; +import { + assertEquals, + assertNotEquals, + assertRejects, +} from "jsr:@std/assert@1.0.10"; import { Client as ScramClient, Reason } from "../connection/scram.ts"; Deno.test("Scram client reproduces RFC 7677 example", async () => { diff --git a/tests/config.ts b/tests/config.ts index 17bf701c..0fb0507a 100644 --- a/tests/config.ts +++ b/tests/config.ts @@ -1,4 +1,4 @@ -import { +import type { ClientConfiguration, ClientOptions, } from "../connection/connection_params.ts"; @@ -15,7 +15,10 @@ let DEV_MODE: string | undefined; try { DEV_MODE = Deno.env.get("DENO_POSTGRES_DEVELOPMENT"); } catch (e) { - if (e instanceof Deno.errors.PermissionDenied) { + if ( + e instanceof Deno.errors.PermissionDenied || + ("NotCapable" in Deno.errors && e instanceof Deno.errors.NotCapable) + ) { throw new Error( "You need to provide ENV access in order to run the test suite", ); diff --git a/tests/connection_params_test.ts b/tests/connection_params_test.ts index d5138784..94df4338 100644 --- a/tests/connection_params_test.ts +++ b/tests/connection_params_test.ts @@ -1,4 +1,5 @@ -import { assertEquals, assertThrows, fromFileUrl } from "./test_deps.ts"; +import { assertEquals, assertThrows } from "jsr:@std/assert@1.0.10"; +import { fromFileUrl } from "@std/path"; import { createParams } from "../connection/connection_params.ts"; import { ConnectionParamsError } from "../client/error.ts"; diff --git a/tests/connection_test.ts b/tests/connection_test.ts index 5cc85539..50cc7dd9 100644 --- a/tests/connection_test.ts +++ b/tests/connection_test.ts @@ -1,9 +1,5 @@ -import { - assertEquals, - assertRejects, - copyStream, - joinPath, -} from "./test_deps.ts"; +import { assertEquals, assertRejects } from "jsr:@std/assert@1.0.10"; +import { join as joinPath } from "@std/path"; import { getClearConfiguration, getClearSocketConfiguration, @@ -25,26 +21,20 @@ function createProxy( const proxy = (async () => { for await (const conn of target) { - let aborted = false; - const outbound = await Deno.connect({ hostname: source.hostname, port: source.port, }); + aborter.signal.addEventListener("abort", () => { conn.close(); outbound.close(); - aborted = true; }); + await Promise.all([ - copyStream(conn, outbound), - copyStream(outbound, conn), + conn.readable.pipeTo(outbound.writable), + outbound.readable.pipeTo(conn.writable), ]).catch(() => {}); - - if (!aborted) { - conn.close(); - outbound.close(); - } } })(); @@ -399,7 +389,7 @@ Deno.test("Closes connection on bad TLS availability verification", async functi await client.connect(); } catch (e) { if ( - e instanceof Error || + e instanceof Error && e.message.startsWith("Could not check if server accepts SSL connections") ) { bad_tls_availability_message = true; @@ -586,19 +576,19 @@ Deno.test("Attempts reconnection on socket disconnection", async () => { // TODO // Find a way to unlink the socket to simulate unexpected socket disconnection -Deno.test("Attempts reconnection when connection is lost", async function () { +Deno.test("Attempts reconnection when connection is lost", async () => { const cfg = getMainConfiguration(); const listener = Deno.listen({ hostname: "127.0.0.1", port: 0 }); const { aborter, proxy } = createProxy(listener, { hostname: cfg.hostname, - port: Number(cfg.port), + port: cfg.port, }); const client = new Client({ ...cfg, hostname: "127.0.0.1", - port: (listener.addr as Deno.NetAddr).port, + port: listener.addr.port, tls: { enabled: false, }, diff --git a/tests/data_types_test.ts b/tests/data_types_test.ts index d4d56103..1dc1c463 100644 --- a/tests/data_types_test.ts +++ b/tests/data_types_test.ts @@ -1,4 +1,5 @@ -import { assertEquals, base64, formatDate, parseDate } from "./test_deps.ts"; +import { assertEquals } from "jsr:@std/assert@1.0.10"; +import { decodeBase64, encodeBase64 } from "@std/encoding/base64"; import { getMainConfiguration } from "./config.ts"; import { generateSimpleClientTest } from "./helpers.ts"; import type { @@ -34,7 +35,7 @@ function generateRandomPoint(max_value = 100): Point { const CHARS = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"; function randomBase64(): string { - return base64.encodeBase64( + return encodeBase64( Array.from( { length: Math.ceil(Math.random() * 256) }, () => CHARS[Math.floor(Math.random() * CHARS.length)], @@ -671,7 +672,7 @@ Deno.test( `SELECT decode('${base64_string}','base64')`, ); - assertEquals(result.rows[0][0], base64.decodeBase64(base64_string)); + assertEquals(result.rows[0][0], decodeBase64(base64_string)); }), ); @@ -691,7 +692,7 @@ Deno.test( assertEquals( result.rows[0][0], - strings.map(base64.decodeBase64), + strings.map(decodeBase64), ); }), ); @@ -931,7 +932,7 @@ Deno.test( ); assertEquals(result.rows[0], [ - parseDate(date_text, "yyyy-MM-dd"), + new Date(date_text), Infinity, ]); }), @@ -941,7 +942,7 @@ Deno.test( "date array", testClient(async (client) => { await client.queryArray(`SET SESSION TIMEZONE TO '${timezone}'`); - const dates = ["2020-01-01", formatDate(new Date(), "yyyy-MM-dd")]; + const dates = ["2020-01-01", (new Date()).toISOString().split("T")[0]]; const { rows: result } = await client.queryArray<[[Date, Date]]>( "SELECT ARRAY[$1::DATE, $2]", @@ -950,7 +951,7 @@ Deno.test( assertEquals( result[0][0], - dates.map((d) => parseDate(d, "yyyy-MM-dd")), + dates.map((d) => new Date(d)), ); }), ); diff --git a/tests/decode_test.ts b/tests/decode_test.ts index 06512911..b2f0657f 100644 --- a/tests/decode_test.ts +++ b/tests/decode_test.ts @@ -17,7 +17,7 @@ import { decodePoint, decodeTid, } from "../query/decoders.ts"; -import { assertEquals, assertThrows } from "./test_deps.ts"; +import { assertEquals, assertThrows } from "jsr:@std/assert@1.0.10"; import { Oid } from "../query/oid.ts"; Deno.test("decodeBigint", function () { diff --git a/tests/encode_test.ts b/tests/encode_test.ts index 784fdaab..eab21868 100644 --- a/tests/encode_test.ts +++ b/tests/encode_test.ts @@ -1,4 +1,4 @@ -import { assertEquals } from "./test_deps.ts"; +import { assertEquals } from "jsr:@std/assert@1.0.10"; import { encodeArgument } from "../query/encode.ts"; // internally `encodeArguments` uses `getTimezoneOffset` to encode Date diff --git a/tests/helpers.ts b/tests/helpers.ts index d1630d3e..e26a7f27 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -1,6 +1,6 @@ import { Client } from "../client.ts"; import { Pool } from "../pool.ts"; -import { type ClientOptions } from "../connection/connection_params.ts"; +import type { ClientOptions } from "../connection/connection_params.ts"; export function generateSimpleClientTest( client_options: ClientOptions, diff --git a/tests/pool_test.ts b/tests/pool_test.ts index fb7c3fcb..3acf920e 100644 --- a/tests/pool_test.ts +++ b/tests/pool_test.ts @@ -1,4 +1,4 @@ -import { assertEquals, delay } from "./test_deps.ts"; +import { assertEquals } from "jsr:@std/assert@1.0.10"; import { getMainConfiguration } from "./config.ts"; import { generatePoolClientTest } from "./helpers.ts"; @@ -11,7 +11,7 @@ Deno.test( assertEquals(POOL.available, 10); const client = await POOL.connect(); const p = client.queryArray("SELECT pg_sleep(0.1) is null, -1 AS id"); - await delay(1); + await new Promise((resolve) => setTimeout(resolve, 1)); assertEquals(POOL.available, 9); assertEquals(POOL.size, 10); await p; @@ -28,7 +28,7 @@ Deno.test( return query; }); const qsPromises = Promise.all(qsThunks); - await delay(1); + await new Promise((resolve) => setTimeout(resolve, 1)); assertEquals(POOL.available, 0); const qs = await qsPromises; assertEquals(POOL.available, 10); @@ -52,7 +52,7 @@ Deno.test( const client_2 = await POOL.connect(); const p = client_2.queryArray("SELECT pg_sleep(0.1) is null, -1 AS id"); - await delay(1); + await new Promise((resolve) => setTimeout(resolve, 1)); assertEquals(POOL.size, size); assertEquals(POOL.available, size - 1); assertEquals(await POOL.initialized(), 0); @@ -75,7 +75,7 @@ Deno.test( }, ); const qsPromises = Promise.all(qsThunks); - await delay(1); + await new Promise((resolve) => setTimeout(resolve, 1)); assertEquals(POOL.available, 0); assertEquals(await POOL.initialized(), 0); const qs = await qsPromises; @@ -140,3 +140,15 @@ Deno.test( ); }), ); + +Deno.test( + "Pool client will be released after `using` block", + testPool(async (POOL) => { + const initialPoolAvailable = POOL.available; + { + using _client = await POOL.connect(); + assertEquals(POOL.available, initialPoolAvailable - 1); + } + assertEquals(POOL.available, initialPoolAvailable); + }), +); diff --git a/tests/query_client_test.ts b/tests/query_client_test.ts index 84e05f94..26966de4 100644 --- a/tests/query_client_test.ts +++ b/tests/query_client_test.ts @@ -8,13 +8,14 @@ import { import { assert, assertEquals, + assertInstanceOf, assertObjectMatch, assertRejects, assertThrows, -} from "./test_deps.ts"; +} from "jsr:@std/assert@1.0.10"; import { getMainConfiguration } from "./config.ts"; -import { PoolClient, QueryClient } from "../client.ts"; -import { ClientOptions } from "../connection/connection_params.ts"; +import type { PoolClient, QueryClient } from "../client.ts"; +import type { ClientOptions } from "../connection/connection_params.ts"; import { Oid } from "../query/oid.ts"; function withClient( @@ -240,6 +241,79 @@ Deno.test( ), ); +Deno.test( + "Custom decoders with arrays", + withClient( + async (client) => { + const result = await client.queryObject( + `SELECT + ARRAY[true, false, true] AS _bool_array, + ARRAY['2024-01-01'::date, '2024-01-02'::date, '2024-01-03'::date] AS _date_array, + ARRAY[1.5:: REAL, 2.5::REAL, 3.5::REAL] AS _float_array, + ARRAY[10, 20, 30] AS _int_array, + ARRAY[ + '{"key1": "value1", "key2": "value2"}'::jsonb, + '{"key3": "value3", "key4": "value4"}'::jsonb, + '{"key5": "value5", "key6": "value6"}'::jsonb + ] AS _jsonb_array, + ARRAY['string1', 'string2', 'string3'] AS _text_array + ;`, + ); + + assertEquals(result.rows, [ + { + _bool_array: [ + { boolean: true }, + { boolean: false }, + { boolean: true }, + ], + _date_array: [ + new Date("2024-01-11T00:00:00.000Z"), + new Date("2024-01-12T00:00:00.000Z"), + new Date("2024-01-13T00:00:00.000Z"), + ], + _float_array: [15, 25, 35], + _int_array: [110, 120, 130], + _jsonb_array: [ + { key1: "value1", key2: "value2" }, + { key3: "value3", key4: "value4" }, + { key5: "value5", key6: "value6" }, + ], + _text_array: ["string1_!", "string2_!", "string3_!"], + }, + ]); + }, + { + controls: { + decoders: { + // convert to object + [Oid.bool]: (value: string) => ({ boolean: value === "t" }), + // 1082 = date : convert to date and add 10 days + "1082": (value: string) => { + const d = new Date(value); + return new Date(d.setDate(d.getDate() + 10)); + }, + // multiply by 20, should not be used! + float4: (value: string) => parseFloat(value) * 20, + // multiply by 10 + float4_array: (value: string, _, parseArray) => + parseArray(value, (v) => parseFloat(v) * 10), + // return 0, should not be used! + [Oid.int4]: () => 0, + // add 100 + [Oid.int4_array]: (value: string, _, parseArray) => + parseArray(value, (v) => parseInt(v, 10) + 100), + // split string and reverse, should not be used! + [Oid.text]: (value: string) => value.split("").reverse(), + // 1009 = text_array : append "_!" to each string + 1009: (value: string, _, parseArray) => + parseArray(value, (v) => `${v}_!`), + }, + }, + }, + ), +); + Deno.test( "Custom decoder precedence", withClient( @@ -284,6 +358,43 @@ Deno.test( ), ); +Deno.test( + "Debug query not in error", + withClient(async (client) => { + const invalid_query = "SELECT this_has $ 'syntax_error';"; + try { + await client.queryObject(invalid_query); + } catch (error) { + assertInstanceOf(error, PostgresError); + assertEquals(error.message, 'syntax error at or near "$"'); + assertEquals(error.query, undefined); + } + }), +); + +Deno.test( + "Debug query in error", + withClient( + async (client) => { + const invalid_query = "SELECT this_has $ 'syntax_error';"; + try { + await client.queryObject(invalid_query); + } catch (error) { + assertInstanceOf(error, PostgresError); + assertEquals(error.message, 'syntax error at or near "$"'); + assertEquals(error.query, invalid_query); + } + }, + { + controls: { + debug: { + queryInError: true, + }, + }, + }, + ), +); + Deno.test( "Array arguments", withClient(async (client) => { @@ -796,7 +907,7 @@ Deno.test( ); Deno.test( - "Object query field names aren't transformed when camelcase is disabled", + "Object query field names aren't transformed when camel case is disabled", withClient(async (client) => { const record = { pos_x: "100", @@ -806,7 +917,7 @@ Deno.test( const { rows: result } = await client.queryObject({ args: [record.pos_x, record.pos_y, record.prefix_name_suffix], - camelcase: false, + camelCase: false, text: "SELECT $1 AS POS_X, $2 AS POS_Y, $3 AS PREFIX_NAME_SUFFIX", }); @@ -815,7 +926,7 @@ Deno.test( ); Deno.test( - "Object query field names are transformed when camelcase is enabled", + "Object query field names are transformed when camel case is enabled", withClient(async (client) => { const record = { posX: "100", @@ -825,7 +936,7 @@ Deno.test( const { rows: result } = await client.queryObject({ args: [record.posX, record.posY, record.prefixNameSuffix], - camelcase: true, + camelCase: true, text: "SELECT $1 AS POS_X, $2 AS POS_Y, $3 AS PREFIX_NAME_SUFFIX", }); @@ -846,13 +957,13 @@ Deno.test( ); Deno.test( - "Object query explicit fields override camelcase", + "Object query explicit fields override camel case", withClient(async (client) => { const record = { field_1: "A", field_2: "B", field_3: "C" }; const { rows: result } = await client.queryObject({ args: [record.field_1, record.field_2, record.field_3], - camelcase: true, + camelCase: true, fields: ["field_1", "field_2", "field_3"], text: "SELECT $1 AS POS_X, $2 AS POS_Y, $3 AS PREFIX_NAME_SUFFIX", }); @@ -888,7 +999,7 @@ Deno.test( await assertRejects( () => client.queryObject({ - camelcase: true, + camelCase: true, text: `SELECT 1 AS "fieldX", 2 AS field_x`, }), Error, diff --git a/tests/test_deps.ts b/tests/test_deps.ts index 1fce7027..cb56ee54 100644 --- a/tests/test_deps.ts +++ b/tests/test_deps.ts @@ -1,11 +1,9 @@ -export * from "../deps.ts"; export { assert, assertEquals, + assertInstanceOf, assertNotEquals, assertObjectMatch, assertRejects, assertThrows, -} from "https://deno.land/std@0.214.0/assert/mod.ts"; -export { format as formatDate } from "https://deno.land/std@0.214.0/datetime/format.ts"; -export { copy as copyStream } from "https://deno.land/std@0.214.0/io/copy.ts"; +} from "jsr:@std/assert@1.0.10"; diff --git a/tests/utils_test.ts b/tests/utils_test.ts index d5e418d3..40542ea7 100644 --- a/tests/utils_test.ts +++ b/tests/utils_test.ts @@ -1,5 +1,5 @@ -import { assertEquals, assertThrows } from "./test_deps.ts"; -import { parseConnectionUri, Uri } from "../utils/utils.ts"; +import { assertEquals, assertThrows } from "jsr:@std/assert@1.0.10"; +import { parseConnectionUri, type Uri } from "../utils/utils.ts"; import { DeferredAccessStack, DeferredStack } from "../utils/deferred.ts"; class LazilyInitializedObject { diff --git a/tools/convert_to_jsr.ts b/tools/convert_to_jsr.ts deleted file mode 100644 index 9843f572..00000000 --- a/tools/convert_to_jsr.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { walk } from "https://deno.land/std@0.214.0/fs/walk.ts"; -import denoConfig from "../deno.json" with { type: "json" }; - -const STD_SPECIFIER_REGEX = - /https:\/\/deno\.land\/std@(\d+\.\d+\.\d+)\/(\w+)\/(.+)\.ts/g; -const POSTGRES_X_SPECIFIER = "https://deno.land/x/postgres/mod.ts"; -const POSTGRES_JSR_SPECIFIER = `jsr:${denoConfig.name}`; - -function toStdJsrSpecifier( - _full: string, - _version: string, - module: string, - path: string, -): string { - /** - * @todo(iuioiua) Restore the dynamic use of the `version` argument - * once 0.214.0 is released. - */ - const version = "0.213.1"; - return path === "mod" - ? `jsr:@std/${module}@${version}` - : `jsr:@std/${module}@${version}/${path}`; -} - -for await ( - const entry of walk(".", { - includeDirs: false, - exts: [".ts", ".md"], - skip: [/docker/, /.github/, /tools/], - followSymlinks: false, - }) -) { - const text = await Deno.readTextFile(entry.path); - const newText = text - .replaceAll(STD_SPECIFIER_REGEX, toStdJsrSpecifier) - .replaceAll(POSTGRES_X_SPECIFIER, POSTGRES_JSR_SPECIFIER); - await Deno.writeTextFile(entry.path, newText); -} diff --git a/utils/deferred.ts b/utils/deferred.ts index f4b4c10a..9d650d90 100644 --- a/utils/deferred.ts +++ b/utils/deferred.ts @@ -22,7 +22,9 @@ export class DeferredStack { async pop(): Promise { if (this.#elements.length > 0) { return this.#elements.pop()!; - } else if (this.#size < this.#max_size && this.#creator) { + } + + if (this.#size < this.#max_size && this.#creator) { this.#size++; return await this.#creator(); } diff --git a/utils/utils.ts b/utils/utils.ts index ae7ccee8..f0280fb7 100644 --- a/utils/utils.ts +++ b/utils/utils.ts @@ -1,4 +1,4 @@ -import { bold, yellow } from "../deps.ts"; +import { bold, yellow } from "@std/fmt/colors"; export function readInt16BE(buffer: Uint8Array, offset: number): number { offset = offset >>> 0; @@ -93,7 +93,7 @@ export function parseConnectionUri(uri: string): Uri { } } catch (_e) { console.error( - bold(yellow("Failed to decode URL host") + "\nDefaulting to raw host"), + bold(`${yellow("Failed to decode URL host")}\nDefaulting to raw host`), ); } @@ -108,8 +108,9 @@ export function parseConnectionUri(uri: string): Uri { } catch (_e) { console.error( bold( - yellow("Failed to decode URL password") + - "\nDefaulting to raw password", + `${ + yellow("Failed to decode URL password") + }\nDefaulting to raw password`, ), ); }