diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 00000000..d0a004f3 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,35 @@ +--- +name: Bug report +about: Create a report to help us fix and improve +title: "" +labels: "" +assignees: "" +--- + +**Describe the bug** + +A clear and concise description of what the bug is. + +**To Reproduce** + +Steps to reproduce the behavior: + +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** + +A clear and concise description of what you expected to happen. + +**Screenshots** + +If applicable, add screenshots to help explain your problem. + +**Additional context** + +If applicable, add any other context about the problem here. + +- deno-postgres version: +- deno version: diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 00000000..8e043678 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,25 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: "" +labels: "" +assignees: "" +--- + +**Is your feature request related to a problem? Please describe.** + +A clear and concise description of what the problem is. Ex. I'm always +frustrated when [...] + +**Describe the solution you'd like** + +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** + +A clear and concise description of any alternative solutions or features you've +considered. + +**Additional context** + +Add any other context or screenshots about the feature request here. 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 new file mode 100644 index 00000000..1b2de0f5 --- /dev/null +++ b/.github/workflows/publish_jsr.yml @@ -0,0 +1,76 @@ +name: Publish to JSR + +on: + push: + branches: + - main + +jobs: + publish: + runs-on: ubuntu-latest + timeout-minutes: 30 + + permissions: + contents: write + id-token: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Deno + uses: denoland/setup-deno@v1 + with: + deno-version: v2.x + + - name: Extract version from deno.json + id: get_version + run: | + VERSION=$(jq -r .version < deno.json) + echo "version=$VERSION" >> $GITHUB_OUTPUT + + - 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: Build tests container + run: docker compose build tests + + - name: 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: 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 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 7e439982..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@v0.17.1/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 user experience - -`deno-postgres` is being developed based on 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@v0.17.1/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,64 +70,86 @@ await client.connect(); await client.end(); ``` -For more examples visit the documentation available at -[https://deno-postgres.com/](https://deno-postgres.com/) - -## Documentation - -The documentation is available on the deno-postgres website -[https://deno-postgres.com/](https://deno-postgres.com/) +## Deno compatibility -Join me on [Discord](https://discord.gg/HEdTCvZUSf) as well! It's a good place -to discuss bugs and features before opening issues +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 + +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. + +## 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 ### Prerequisites -- You must have `docker` and `docker-compose` installed in your machine +- You must have `docker` and `docker-compose` installed on your machine + - https://docs.docker.com/get-docker/ - https://docs.docker.com/compose/install/ -- You don't need `deno` installed in your machine to run the tests, since it - will be installed in the Docker container when you build it. However you will - need it in order to run the linter and formatter locally +- You don't need `deno` installed in your machine to run the tests since it will + be installed in the Docker container when you build it. However, you will need + it to run the linter and formatter locally + - https://deno.land/ - - `deno upgrade --version 1.7.1` - - `dvm install 1.7.1 && dvm use 1.7.1` + - `deno upgrade stable` + - `dvm install stable && dvm use stable` -- You don't need to install Postgres locally in your machine in order to test - the library, it will run as a service in the Docker container when you build - it +- 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 ### Running the tests The tests are found under the `./tests` folder, and they are based on query -result assertions +result assertions. -In order to run the tests run the following commands +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 It is recommended that you don't rely on any previously initialized data for -your tests, instead of that create all the data you need at the moment of -running the tests +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 () => { - await client.queryArray( - `CREATE TEMP TABLE MY_TEST (X INTEGER);`, - ); - await client.queryArray( - `INSERT INTO MY_TEST (X) VALUES (1);`, - ); + await client.queryArray(`CREATE TEMP TABLE MY_TEST (X INTEGER);`); + await client.queryArray(`INSERT INTO MY_TEST (X) VALUES (1);`); const result = await client.queryObject<{ x: number }>({ text: `SELECT X FROM MY_TEST`, fields: ["x"], @@ -119,8 +160,8 @@ Deno.test("INSERT works correctly", async () => { ### Setting up an advanced development environment -More advanced features such as the Deno inspector, test and permission -filtering, database inspection and test code lens can be achieved by setting up +More advanced features, such as the Deno inspector, test, and permission +filtering, database inspection, and test code lens can be achieved by setting up a local testing environment, as shown in the following steps: 1. Start the development databases using the Docker service with the command\ @@ -134,43 +175,29 @@ a local testing environment, as shown in the following steps: all environments The `DENO_POSTGRES_DEVELOPMENT` variable will tell the testing pipeline to - use the local testing settings specified in `tests/config.json`, instead of - the CI settings -3. Run the tests manually by using the command\ - `deno test --unstable -A` - -## Deno compatibility + use the local testing settings specified in `tests/config.json` instead of + the CI settings. -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 library - -This situation will become more stable as `std` and `deno-postgres` approach 1.0 - -| Deno version | Min driver version | Max driver version | -| ------------- | ------------------ | ------------------ | -| 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.15.0 | 0.13.0 | | -| 1.16.0 | 0.14.0 | 0.14.3 | -| 1.17.0 | 0.15.0 | | +3. Run the tests manually by using the command\ + `deno test -A` ## Contributing guidelines -When contributing to repository make sure to: +When contributing to the repository, make sure to: -1. All features and fixes must have an open issue in order to be discussed -2. All public interfaces must be typed and have a corresponding JS block +1. All features and fixes must have an open issue to be discussed +2. All public interfaces must be typed and have a corresponding JSDoc block explaining their usage 3. All code must pass the format and lint checks enforced by `deno fmt` and - `deno lint` respectively. The build will not pass the tests if these - conditions are not met. Ignore rules will be accepted in the code base when - their respective justification is given in a comment -4. All features and fixes must have a corresponding test added in order to be - accepted + `deno lint` respectively. The build will only pass the tests if these + conditions are met. Ignore rules will be accepted in the code base when their + respective justification is given in a comment +4. All features and fixes must have a corresponding test added to be accepted + +## Maintainers guidelines + +When publishing a new version, ensure that the `version` field in `deno.json` +has been updated to match the new version. ## License @@ -179,5 +206,5 @@ preserved their individual licenses and copyrights. Everything is licensed under the MIT License. -All additional work is copyright 2018 - 2022 — Bartłomiej Iwańczuk and Steven -Guerrero — All rights reserved. +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 7bbc97e9..f064e976 100644 --- a/client.ts +++ b/client.ts @@ -19,6 +19,9 @@ import { import { Transaction, type TransactionOptions } from "./query/transaction.ts"; import { isTemplateString } from "./utils/utils.ts"; +/** + * The Session representing the current state of the connection + */ export interface Session { /** * This is the code for the transaction currently locking the connection. @@ -43,19 +46,31 @@ export interface Session { transport: "tcp" | "socket" | undefined; } +/** + * An abstract class used to define common database client properties and methods + */ export abstract class QueryClient { #connection: Connection; #terminated = false; #transaction: string | null = null; + /** + * Create a new query client + */ constructor(connection: Connection) { this.#connection = connection; } - get connected() { + /** + * Indicates if the client is currently connected to the database + */ + get connected(): boolean { return this.#connection.connected; } + /** + * The current session metadata + */ get session(): Session { return { current_transaction: this.#transaction, @@ -67,12 +82,13 @@ export abstract class QueryClient { #assertOpenConnection() { if (this.#terminated) { - throw new Error( - "Connection to the database has been terminated", - ); + throw new Error("Connection to the database has been terminated"); } } + /** + * Close the connection to the database + */ protected async closeConnection() { if (this.connected) { await this.#connection.end(); @@ -89,50 +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 "./client.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 "./client.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 "./client.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 @@ -147,8 +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 "./client.ts"; - * + * import { Client } from "jsr:@db/postgres"; * const client = new Client(); * const transaction = await client.createTransaction("my_transaction", { isolation_level: "repeatable_read" }); * ``` @@ -156,8 +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 "./client.ts"; - * + * import { Client } from "jsr:@db/postgres"; * const client = new Client(); * const transaction = await client.createTransaction("my_transaction", { isolation_level: "serializable" }); * ``` @@ -170,8 +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 "./client.ts"; - * + * import { Client } from "jsr:@db/postgres"; * const client = new Client(); * const transaction = await client.createTransaction("my_transaction", { read_only: true }); * ``` @@ -182,21 +202,29 @@ export abstract class QueryClient { * you can do the following: * * ```ts - * import { Client } from "./client.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 * https://www.postgresql.org/docs/14/sql-set-transaction.html */ createTransaction(name: string, options?: TransactionOptions): Transaction { + if (!name) { + throw new Error("Transaction name must be a non-empty string"); + } + this.#assertOpenConnection(); return new Transaction( @@ -239,54 +267,67 @@ export abstract class QueryClient { async #executeQuery( _query: Query, ): Promise>; - async #executeQuery( - query: Query, - ): Promise { + async #executeQuery(query: Query): Promise { return await this.#connection.query(query); } /** - * This method allows executed queries to be retrieved as array entries. - * It supports a generic interface in order to type the entries retrieved by the query + * 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 "./client.ts"; - * + * import { Client } from "jsr:@db/postgres"; * const my_client = new Client(); * - * const {rows} = await my_client.queryArray( + * 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 - * ``` * - * You can pass type arguments to the query in order to hint TypeScript what the return value will be - * ```ts - * import { Client } from "./client.ts"; - * - * const my_client = new Client(); - * const { rows } = await my_client.queryArray<[number, string]>( + * const { rows: rows2 } = await my_client.queryArray<[number, string]>( * "SELECT ID, NAME FROM CLIENTS" * ); // Array<[number, string]> + * + * await my_client.end(); * ``` + */ + async queryArray>( + query: string, + args?: QueryArguments, + ): Promise>; + /** + * Use the configuration object for more advance options to execute the query * - * It also allows you to execute prepared statements with template strings + * ```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>( + config: QueryOptions, + ): Promise>; + /** + * Execute prepared statements with template strings * * ```ts - * import { Client } from "./client.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>( - query: string, - args?: QueryArguments, - ): Promise>; - async queryArray>( - config: QueryOptions, - ): Promise>; async queryArray>( strings: TemplateStringsArray, ...args: unknown[] @@ -324,78 +365,68 @@ export abstract class QueryClient { } /** - * This method allows executed queries to be retrieved as object entries. - * It supports a generic interface in order to type the entries retrieved by the query + * 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 "./client.ts"; - * + * import { Client } from "jsr:@db/postgres"; * const my_client = new Client(); * - * { - * const { rows } = await my_client.queryObject( - * "SELECT ID, NAME FROM CLIENTS" - * ); // Record - * } + * const { rows: rows1 } = await my_client.queryObject( + * "SELECT ID, NAME FROM CLIENTS" + * ); // Record * - * { - * const { rows } = await my_client.queryObject<{id: number, name: string}>( - * "SELECT ID, NAME FROM CLIENTS" - * ); // Array<{id: number, name: string}> - * } - * ``` + * const { rows: rows2 } = await my_client.queryObject<{id: number, name: string}>( + * "SELECT ID, NAME FROM CLIENTS" + * ); // Array<{id: number, name: string}> * - * You can also map the expected results to object fields using the configuration interface. - * This will be assigned in the order they were provided + * await my_client.end(); + * ``` + */ + async queryObject( + query: string, + args?: QueryArguments, + ): Promise>; + /** + * Use the configuration object for more advance options to execute the query * * ```ts - * import { Client } from "./client.ts"; - * + * import { Client } from "jsr:@db/postgres"; * const my_client = new Client(); * - * { - * const {rows} = await my_client.queryObject( - * "SELECT ID, NAME FROM CLIENTS" - * ); - * - * console.log(rows); // [{id: 78, name: "Frank"}, {id: 15, name: "Sarah"}] - * } + * const { rows: rows1 } = await my_client.queryObject( + * "SELECT ID, NAME FROM CLIENTS" + * ); + * console.log(rows1); // [{id: 78, name: "Frank"}, {id: 15, name: "Sarah"}] * - * { - * const {rows} = await my_client.queryObject({ - * text: "SELECT ID, NAME FROM CLIENTS", - * fields: ["personal_id", "complete_name"], - * }); + * const { rows: rows2 } = await my_client.queryObject({ + * text: "SELECT ID, NAME FROM CLIENTS", + * fields: ["personal_id", "complete_name"], + * }); + * console.log(rows2); // [{personal_id: 78, complete_name: "Frank"}, {personal_id: 15, complete_name: "Sarah"}] * - * console.log(rows); // [{personal_id: 78, complete_name: "Frank"}, {personal_id: 15, complete_name: "Sarah"}] - * } + * await my_client.end(); * ``` - * - * It also allows you to execute prepared statements with template strings + */ + async queryObject( + config: QueryObjectOptions, + ): Promise>; + /** + * Execute prepared statements with template strings * * ```ts - * import { Client } from "./client.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( - query: string, - args?: QueryArguments, - ): Promise>; - async queryObject( - config: QueryObjectOptions, - ): Promise>; async queryObject( query: TemplateStringsArray, ...args: unknown[] ): Promise>; - async queryObject< - T = Record, - >( + async queryObject>( query_template_or_config: | string | QueryObjectOptions @@ -433,6 +464,9 @@ export abstract class QueryClient { return await this.#executeQuery(query); } + /** + * Resets the transaction session metadata + */ protected resetSessionMetadata() { this.#transaction = null; } @@ -443,11 +477,10 @@ export abstract class QueryClient { * statements asynchronously * * ```ts - * import { Client } from "./client.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(); * ``` * @@ -455,25 +488,26 @@ export abstract class QueryClient { * for concurrency capabilities check out connection pools * * ```ts - * import { Client } from "./client.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(); * ``` */ export class Client extends QueryClient { + /** + * Create a new client + */ constructor(config?: ClientOptions | ConnectionString) { super( new Connection(createParams(config), async () => { @@ -483,9 +517,15 @@ export class Client extends QueryClient { } } +/** + * A client used specifically by a connection pool + */ export class PoolClient extends QueryClient { #release: () => void; + /** + * Create a new Client used by the pool + */ constructor(config: ClientConfiguration, releaseCallback: () => void) { super( new Connection(config, async () => { @@ -495,10 +535,17 @@ export class PoolClient extends QueryClient { this.#release = releaseCallback; } + /** + * Releases the client back to the pool + */ release() { this.#release(); // Cleanup all session related metadata this.resetSessionMetadata(); } + + [Symbol.dispose]() { + this.release(); + } } diff --git a/client/error.ts b/client/error.ts index 70d3786c..fa759980 100644 --- a/client/error.ts +++ b/client/error.ts @@ -1,38 +1,65 @@ -import { type Notice } from "../connection/message.ts"; +import type { Notice } from "../connection/message.ts"; +/** + * A connection error + */ export class ConnectionError extends Error { + /** + * Create a new ConnectionError + */ constructor(message?: string) { super(message); this.name = "ConnectionError"; } } +/** + * A connection params error + */ export class ConnectionParamsError extends Error { - constructor(message: string, cause?: Error) { + /** + * Create a new ConnectionParamsError + */ + constructor(message: string, cause?: unknown) { super(message, { cause }); this.name = "ConnectionParamsError"; } } +/** + * A Postgres database error + */ export class PostgresError extends Error { + /** + * The fields of the notice message + */ public fields: Notice; - constructor(fields: Notice) { + /** + * The query that caused the error + */ + public query: string | undefined; + + /** + * Create a new PostgresError + */ + constructor(fields: Notice, query?: string) { super(fields.message); this.fields = fields; + this.query = query; this.name = "PostgresError"; } } +/** + * A transaction error + */ export class TransactionError extends Error { - constructor( - transaction_name: string, - cause: PostgresError, - ) { - super( - `The transaction "${transaction_name}" has been aborted`, - { cause }, - ); + /** + * Create a transaction error with a message and a cause + */ + constructor(transaction_name: string, cause: PostgresError) { + super(`The transaction "${transaction_name}" has been aborted`, { cause }); this.name = "TransactionError"; } } diff --git a/connection/auth.ts b/connection/auth.ts index abc92ab5..e77b8830 100644 --- a/connection/auth.ts +++ b/connection/auth.ts @@ -1,12 +1,10 @@ -import { crypto, hex } from "../deps.ts"; +import { crypto } from "@std/crypto/crypto"; +import { encodeHex } from "@std/encoding/hex"; const encoder = new TextEncoder(); -const decoder = new TextDecoder(); async function md5(bytes: Uint8Array): Promise { - return decoder.decode( - hex.encode(new Uint8Array(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 1764a25b..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 = @@ -86,9 +81,7 @@ function assertSuccessfulAuthentication(auth_message: Message) { throw new PostgresError(parseNoticeMessage(auth_message)); } - if ( - auth_message.type !== INCOMING_AUTHENTICATION_MESSAGES.AUTHENTICATION - ) { + if (auth_message.type !== INCOMING_AUTHENTICATION_MESSAGES.AUTHENTICATION) { throw new Error(`Unexpected auth response: ${auth_message.type}.`); } @@ -99,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(); @@ -109,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; @@ -118,27 +127,25 @@ export class Connection { #onDisconnection: () => Promise; #packetWriter = new PacketWriter(); #pid?: number; - #queryLock: DeferredStack = new DeferredStack( - 1, - [undefined], - ); + #queryLock: DeferredStack = new DeferredStack(1, [undefined]); // TODO // Find out what the secret key is for #secretKey?: number; #tls?: boolean; #transport?: "tcp" | "socket"; + #connWritable!: WritableStreamDefaultWriter; - get pid() { + get pid(): number | undefined { return this.#pid; } /** Indicates if the connection is carried over TLS */ - get tls() { + get tls(): boolean | undefined { return this.#tls; } /** Indicates the connection protocol used */ - get transport() { + get transport(): "tcp" | "socket" | undefined { return this.#transport; } @@ -150,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 @@ -172,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); } @@ -180,13 +213,9 @@ export class Connection { async #serverAcceptsTLS(): Promise { const writer = this.#packetWriter; writer.clear(); - writer - .addInt32(8) - .addInt32(80877103) - .join(); + 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); @@ -216,16 +245,20 @@ export class Connection { // TODO: recognize other parameters writer.addCString("user").addCString(this.#connection_params.user); writer.addCString("database").addCString(this.#connection_params.database); - writer.addCString("application_name").addCString( - this.#connection_params.applicationName, - ); + writer + .addCString("application_name") + .addCString(this.#connection_params.applicationName); const connection_options = Object.entries(this.#connection_params.options); if (connection_options.length > 0) { // The database expects options in the --key=value - writer.addCString("options").addCString( - connection_options.map(([key, value]) => `--${key}=${value}`).join(" "), - ); + writer + .addCString("options") + .addCString( + connection_options + .map(([key, value]) => `--${key}=${value}`) + .join(" "), + ); } // terminator after all parameters were writter @@ -236,30 +269,23 @@ export class Connection { writer.clear(); - const finalBuffer = writer - .addInt32(bodyLength) - .add(bodyBuffer) - .join(); + 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(); } async #openConnection(options: ConnectOptions) { - // @ts-ignore This will throw in runtime if the options passed to it are socket related and deno is running + // @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) { if (Deno.build.os === "windows") { - throw new Error( - "Socket connection is only available on UNIX systems", - ); + throw new Error("Socket connection is only available on UNIX systems"); } const socket = await Deno.stat(path); @@ -284,22 +310,18 @@ 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() { this.connected = false; this.#packetWriter = new PacketWriter(); this.#pid = undefined; - this.#queryLock = new DeferredStack( - 1, - [undefined], - ); + this.#queryLock = new DeferredStack(1, [undefined]); this.#secretKey = undefined; this.#tls = undefined; this.#transport = undefined; @@ -319,14 +341,10 @@ export class Connection { this.#closeConnection(); const { - hostname, host_type, + hostname, port, - tls: { - enabled: tls_enabled, - enforce: tls_enforced, - caCertificates, - }, + tls: { caCertificates, enabled: tls_enabled, enforce: tls_enforced }, } = this.#connection_params; if (host_type === "socket") { @@ -334,24 +352,25 @@ 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"; if (tls_enabled) { // If TLS is disabled, we don't even try to connect. - const accepts_tls = await this.#serverAcceptsTLS() - .catch((e) => { - // Make sure to close the connection if the TLS validation throws - this.#closeConnection(); - throw e; - }); + const accepts_tls = await this.#serverAcceptsTLS().catch((e) => { + // Make sure to close the connection if the TLS validation throws + this.#closeConnection(); + throw e; + }); // 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, }); @@ -360,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"), ); @@ -387,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( @@ -428,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}`); } @@ -460,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) { @@ -484,7 +509,7 @@ export class Connection { } if (interval > 0) { - await delay(interval); + await new Promise((resolve) => setTimeout(resolve, interval)); } } try { @@ -558,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(); } @@ -580,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(); } @@ -608,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) { @@ -636,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) { @@ -657,30 +678,23 @@ export class Connection { `Unexpected message in SASL finalization: ${maybe_sasl_continue.type}`, ); } - const sasl_final = utf8.decode( - maybe_sasl_final.reader.readAllBytes(), - ); + const sasl_final = utf8.decode(maybe_sasl_final.reader.readAllBytes()); await client.receiveResponse(sasl_final); // Return authentication result return this.#readMessage(); } - async #simpleQuery( - query: Query, - ): Promise; + async #simpleQuery(query: Query): Promise; async #simpleQuery( query: Query, ): Promise; - async #simpleQuery( - query: Query, - ): Promise { + async #simpleQuery(query: Query): Promise { this.#packetWriter.clear(); 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) { @@ -689,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 @@ -697,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( @@ -708,7 +730,7 @@ export class Connection { case INCOMING_QUERY_MESSAGES.DATA_ROW: { const row_data = parseRowDataMessage(current_message); try { - result.insertRow(row_data); + result.insertRow(row_data, this.#connection_params.controls); } catch (e) { error = e; } @@ -718,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; } @@ -754,12 +783,10 @@ export class Connection { .addCString(query.text) .addInt16(0) .flush(0x50); - await this.#bufWriter.write(buffer); + await this.#connWritable.write(buffer); } - async #appendArgumentsToMessage( - query: Query, - ) { + async #appendArgumentsToMessage(query: Query) { this.#packetWriter.clear(); const hasBinaryArgs = query.args.some((arg) => arg instanceof Uint8Array); @@ -773,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) { @@ -793,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); } /** @@ -808,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() { @@ -818,22 +845,19 @@ 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 // Rename process function to a more meaningful name and move out of class - async #processErrorUnsafe( - msg: Message, - recoverable = true, - ) { + async #processErrorUnsafe(msg: Message, recoverable = true) { const error = new PostgresError(parseNoticeMessage(msg)); if (recoverable) { let maybe_ready_message = await this.#readMessage(); @@ -847,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 { @@ -860,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) { @@ -870,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: @@ -890,7 +926,7 @@ export class Connection { case INCOMING_QUERY_MESSAGES.DATA_ROW: { const row_data = parseRowDataMessage(current_message); try { - result.insertRow(row_data); + result.insertRow(row_data, this.#connection_params.controls); } catch (e) { error = e; } @@ -900,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; } @@ -930,26 +973,32 @@ export class Connection { return result; } - async query( - query: Query, - ): Promise; - async query( - query: Query, - ): Promise; - async query( - query: Query, - ): Promise { + async query(query: Query): Promise; + async query(query: Query): Promise; + async query(query: Query): Promise { if (!this.connected) { await this.startup(true); } 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(); @@ -963,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 38c46711..a55fb804 100644 --- a/connection/connection_params.ts +++ b/connection/connection_params.ts @@ -1,6 +1,9 @@ import { parseConnectionUri } from "../utils/utils.ts"; import { ConnectionParamsError } from "../client/error.ts"; -import { fromFileUrl, isAbsolute } from "../deps.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 @@ -21,7 +24,7 @@ import { fromFileUrl, isAbsolute } from "../deps.ts"; export type ConnectionString = string; /** - * This function retrieves the connection options from the environmental variables + * Retrieves the connection options from the environmental variables * as they are, without any extra parsing * * It will throw if no env permission was provided on startup @@ -38,6 +41,7 @@ function getPgEnv(): ClientOptions { }; } +/** Additional granular database connection options */ export interface ConnectionOptions { /** * By default, any client will only attempt to stablish @@ -59,16 +63,12 @@ export interface ConnectionOptions { } /** https://www.postgresql.org/docs/14/libpq-ssl.html#LIBPQ-SSL-PROTECTION */ -type TLSModes = - | "disable" - | "prefer" - | "require" - | "verify-ca" - | "verify-full"; +type TLSModes = "disable" | "prefer" | "require" | "verify-ca" | "verify-full"; -// TODO -// Refactor enabled and enforce into one single option for 1.0 +/** The Transport Layer Security (TLS) protocol options to be used by the database connection */ export interface TLSOptions { + // TODO + // Refactor enabled and enforce into one single option for 1.0 /** * If TLS support is enabled or not. If the server requires TLS, * the connection will fail. @@ -77,7 +77,7 @@ export interface TLSOptions { */ enabled: boolean; /** - * This will force the connection to run over TLS + * Forces the connection to run over TLS * If the server doesn't support TLS, the connection will fail * * Default: `false` @@ -94,42 +94,130 @@ export interface TLSOptions { caCertificates: string[]; } -export interface ClientOptions { +/** + * 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 | OidType]?: DecoderFunction; +}; + +/** + * A decoder function that takes a string value and returns a parsed value of some type. + * + * @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, + 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 + * + * `string` : all values are returned as string, and the user has to take care of parsing + * `auto` : deno-postgres parses the data into JS objects (as many as possible implemented, non-implemented parsers would still return strings) + * + * Default: `auto` + * + * Future strategies might include: + * - `strict` : deno-postgres parses the data into JS objects, and if a parser is not implemented, it throws an error + * - `raw` : the data is returned as Uint8Array + */ + decodeStrategy?: DecodeStrategy; + + /** + * 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 ClientControls.decodeStrategy}. Each key in the dictionary is the column OID type number, and the value is + * the decoder function. You can use the `Oid` object to set the decoder functions. + * + * @example + * ```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 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)), + * } + * } + * ``` + */ + decoders?: Decoders; +}; + +/** The Client database connection options */ +export type ClientOptions = { + /** Name of the application connecing to the database */ applicationName?: string; + /** Additional connection options */ connection?: Partial; + /** Control the client behavior */ + controls?: ClientControls; + /** The database name */ database?: string; + /** The name of the host */ hostname?: string; + /** The type of host connection */ host_type?: "tcp" | "socket"; + /** + * Additional connection URI options + * https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-PARAMKEYWORDS + */ options?: string | Record; + /** The database user password */ password?: string; + /** The database port used by the connection */ port?: string | number; + /** */ tls?: Partial; + /** The database user */ user?: string; -} - -export interface ClientConfiguration { - applicationName: string; - connection: ConnectionOptions; - database: string; - hostname: string; - host_type: "tcp" | "socket"; - options: Record; - password?: string; - port: number; - tls: TLSOptions; - user: string; -} +}; + +/** The configuration options required to set up a Client instance */ +export type ClientConfiguration = + & Required< + Omit< + ClientOptions, + "password" | "port" | "tls" | "connection" | "options" | "controls" + > + > + & { + connection: ConnectionOptions; + controls?: ClientControls; + options: Record; + password?: string; + port: number; + tls: TLSOptions; + }; function formatMissingParams(missingParams: string[]) { - return `Missing connection parameters: ${ - missingParams.join( - ", ", - ) - }`; + return `Missing connection parameters: ${missingParams.join(", ")}`; } /** - * This validates the options passed are defined and have a value other than null + * Validates the options passed are defined and have a value other than null * or empty string, it throws a connection error otherwise * * @param has_env_access This parameter will change the error message if set to true, @@ -164,7 +252,7 @@ function assertRequiredOptions( // TODO // Support more options from the spec -/** options from URI per https://www.postgresql.org/docs/14/libpq-connect.html#LIBPQ-CONNSTRING */ +/** options from URI per https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNSTRING */ interface PostgresUri { application_name?: string; dbname?: string; @@ -201,9 +289,7 @@ function parseOptionsArgument(options: string): Record { } else if (/^--\w/.test(args[x])) { transformed_args.push(args[x].slice(2)); } else { - throw new Error( - `Value "${args[x]}" is not a valid options argument`, - ); + throw new Error(`Value "${args[x]}" is not a valid options argument`); } } @@ -237,14 +323,11 @@ function parseOptionsFromUri(connection_string: string): ClientOptions { // Treat as sslmode=require sslmode: uri.params.ssl === "true" ? "require" - : uri.params.sslmode as TLSModes, + : (uri.params.sslmode as TLSModes), user: uri.user || uri.params.user, }; } catch (e) { - throw new ConnectionParamsError( - `Could not parse the connection string`, - e, - ); + throw new ConnectionParamsError("Could not parse the connection string", e); } if (!["postgres", "postgresql"].includes(postgres_uri.driver)) { @@ -255,7 +338,7 @@ function parseOptionsFromUri(connection_string: string): ClientOptions { // No host by default means socket connection const host_type = postgres_uri.host - ? (isAbsolute(postgres_uri.host) ? "socket" : "tcp") + ? isAbsolute(postgres_uri.host) ? "socket" : "tcp" : "socket"; const options = postgres_uri.options @@ -302,7 +385,10 @@ function parseOptionsFromUri(connection_string: string): ClientOptions { } const DEFAULT_OPTIONS: - & Omit + & Omit< + ClientConfiguration, + "database" | "user" | "hostname" + > & { host: string; socket: string } = { applicationName: "deno_postgres", connection: { @@ -333,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; @@ -360,18 +454,13 @@ export function createParams( if (parsed_host.protocol === "file:") { host = fromFileUrl(parsed_host); } else { - throw new Error( - "The provided host is not a file path", - ); + throw new Error("The provided host is not a file path"); } } else { host = socket; } } catch (e) { - throw new ConnectionParamsError( - `Could not parse host "${socket}"`, - e, - ); + throw new ConnectionParamsError(`Could not parse host "${socket}"`, e); } } else { host = provided_host ?? DEFAULT_OPTIONS.host; @@ -404,7 +493,7 @@ export function createParams( } else if (pgEnv.port) { port = Number(pgEnv.port); } else { - port = DEFAULT_OPTIONS.port; + port = Number(DEFAULT_OPTIONS.port); } if (Number.isNaN(port) || port === 0) { throw new ConnectionParamsError( @@ -414,7 +503,7 @@ export function createParams( if (host_type === "socket" && params?.tls) { throw new ConnectionParamsError( - `No TLS options are allowed when host type is set to "socket"`, + 'No TLS options are allowed when host type is set to "socket"', ); } const tls_enabled = !!(params?.tls?.enabled ?? DEFAULT_OPTIONS.tls.enabled); @@ -429,7 +518,8 @@ export function createParams( // TODO // Perhaps username should be taken from the PC user as a default? const connection_options = { - applicationName: params.applicationName ?? pgEnv.applicationName ?? + applicationName: params.applicationName ?? + pgEnv.applicationName ?? DEFAULT_OPTIONS.applicationName, connection: { attempts: params?.connection?.attempts ?? @@ -449,6 +539,7 @@ export function createParams( caCertificates: params?.tls?.caCertificates ?? [], }, user: params.user ?? pgEnv.user, + controls: params.controls, }; assertRequiredOptions( diff --git a/connection/message.ts b/connection/message.ts index edf40866..3fb50dcd 100644 --- a/connection/message.ts +++ b/connection/message.ts @@ -14,29 +14,50 @@ export class Message { } } +/** + * The notice interface defining the fields of a notice message + */ export interface Notice { + /** The notice severity level */ severity: string; + /** The notice code */ code: string; + /** The notice message */ message: string; + /** The additional notice detail */ detail?: string; + /** The notice hint descrip=bing possible ways to fix this notice */ hint?: string; + /** The position of code that triggered the notice */ position?: string; + /** The internal position of code that triggered the notice */ internalPosition?: string; + /** The internal query that triggered the notice */ internalQuery?: string; + /** The where metadata */ where?: string; + /** The database schema */ schema?: string; + /** The table name */ table?: string; + /** The column name */ column?: string; + /** The data type name */ dataType?: string; + /** The constraint name */ constraint?: string; + /** The file name */ file?: string; + /** The line number */ line?: string; + /** The routine name */ routine?: string; } -export function parseBackendKeyMessage( - message: Message, -): { pid: number; secret_key: number } { +export function parseBackendKeyMessage(message: Message): { + pid: number; + secret_key: number; +} { return { pid: message.reader.readInt32(), secret_key: message.reader.readInt32(), diff --git a/connection/message_code.ts b/connection/message_code.ts index 966a02ae..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 = { @@ -33,13 +34,13 @@ export const INCOMING_TLS_MESSAGES = { export const INCOMING_QUERY_MESSAGES = { BIND_COMPLETE: "2", - PARSE_COMPLETE: "1", COMMAND_COMPLETE: "C", DATA_ROW: "D", EMPTY_QUERY: "I", - NO_DATA: "n", NOTICE_WARNING: "N", + NO_DATA: "n", PARAMETER_STATUS: "S", + PARSE_COMPLETE: "1", READY: "Z", ROW_DESCRIPTION: "T", } as const; 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 b197035c..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; @@ -128,13 +128,11 @@ async function deriveKeySignatures( /** Escapes "=" and "," in a string. */ function escape(str: string): string { - return str - .replace(/=/g, "=3D") - .replace(/,/g, "=2C"); + return str.replace(/=/g, "=3D").replace(/,/g, "=2C"); } function generateRandomNonce(size: number): string { - return base64.encode(crypto.getRandomValues(new Uint8Array(size))); + return encodeBase64(crypto.getRandomValues(new Uint8Array(size))); } function parseScramAttributes(message: string): Record { @@ -146,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; } @@ -223,11 +219,13 @@ export class Client { throw new Error(Reason.BadSalt); } try { - salt = base64.decode(attrs.s); + salt = decodeBase64(attrs.s); } catch { throw new Error(Reason.BadSalt); } + if (!salt) throw new Error(Reason.BadSalt); + const iterCount = parseInt(attrs.i) | 0; if (iterCount <= 0) { throw new Error(Reason.BadIterationCount); @@ -261,7 +259,7 @@ export class Client { this.#auth_message += "," + responseWithoutProof; - const proof = base64.encode( + 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.encode( + 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 new file mode 100644 index 00000000..35e10847 --- /dev/null +++ b/deno.json @@ -0,0 +1,14 @@ +{ + "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 1a00ff91..00000000 --- a/deps.ts +++ /dev/null @@ -1,20 +0,0 @@ -export * as base64 from "https://deno.land/std@0.160.0/encoding/base64.ts"; -export * as hex from "https://deno.land/std@0.160.0/encoding/hex.ts"; -export * as date from "https://deno.land/std@0.160.0/datetime/mod.ts"; -export { - BufReader, - BufWriter, -} from "https://deno.land/std@0.160.0/io/buffer.ts"; -export { copy } from "https://deno.land/std@0.160.0/bytes/mod.ts"; -export { crypto } from "https://deno.land/std@0.160.0/crypto/mod.ts"; -export { - type Deferred, - deferred, - delay, -} from "https://deno.land/std@0.160.0/async/mod.ts"; -export { bold, yellow } from "https://deno.land/std@0.160.0/fmt/colors.ts"; -export { - fromFileUrl, - isAbsolute, - join as joinPath, -} from "https://deno.land/std@0.160.0/path/mod.ts"; diff --git a/docker-compose.yml b/docker-compose.yml index 93c0f17a..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 @@ -63,7 +61,7 @@ services: build: . # Name the image to be reused in no_check_tests image: postgres/tests - command: sh -c "/wait && deno test --unstable -A --parallel --check" + command: sh -c "/wait && deno test -A --parallel --check" depends_on: - postgres_clear - postgres_md5 @@ -74,10 +72,26 @@ services: no_check_tests: image: postgres/tests - command: sh -c "/wait && deno test --unstable -A --parallel --no-check" + command: sh -c "/wait && deno test -A --parallel --no-check" depends_on: - tests environment: <<: *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 2135eb05..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@v0.17.1/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. +queries, prepared statements, connection pools, and transactions. ```ts -import { Client } from "https://deno.land/x/postgres@v0.17.1/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@v0.17.1/mod.ts"; +import { Client } from "jsr:@db/postgres"; let config; @@ -53,7 +55,7 @@ config = { host_type: "tcp", password: "password", options: { - "max_index_keys": "32", + max_index_keys: "32", }, port: 5432, user: "user", @@ -73,9 +75,9 @@ await client.end(); ### Connection defaults -The only required parameters for stablishing connection with your database are +The only required parameters for establishing connection with your database are the database name and your user, the rest of them have sensible defaults to save -up time when configuring your connection, such as the following: +uptime when configuring your connection, such as the following: - connection.attempts: "1" - connection.interval: Exponential backoff increasing the time by 500 ms on @@ -92,11 +94,11 @@ up time when configuring your connection, such as the following: Many services provide a connection string as a global format to connect to your database, and `deno-postgres` makes it easy to integrate this into your code by -parsing the options in your connection string as if it was an options object +parsing the options in your connection string as if it were an options object You can create your own connection string by using the following structure: -``` +```txt driver://user:password@host:port/database_name driver://host:port/database_name?user=user&password=password&application_name=my_app @@ -114,26 +116,27 @@ 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 +- sslmode: Allows you to specify the tls configuration for your client; the allowed values are the following: - - verify-full: Same behaviour as `require` - - verify-ca: Same behaviour as `require` - - require: Attempt to stablish a TLS connection, abort the connection if the + - verify-full: Same behavior as `require` + - verify-ca: Same behavior as `require` + - require: Attempt to establish a TLS connection, abort the connection if the negotiation fails - - prefer: Attempt to stablish a TLS connection, default to unencrypted if the + - prefer: Attempt to establish a TLS connection, default to unencrypted if the negotiation fails - disable: Skip TLS connection altogether + - user: If user is not specified in the url, this will be taken instead #### Password encoding One thing that must be taken into consideration is that passwords contained -inside the URL must be properly encoded in order to be passed down to the -database. You can achieve that by using the JavaScript API `encodeURIComponent` -and passing your password as an argument. +inside the URL must be properly encoded to be passed down to the database. You +can achieve that by using the JavaScript API `encodeURIComponent` and passing +your password as an argument. **Invalid**: @@ -145,17 +148,17 @@ and passing your password as an argument. - `postgres://me:Mtx%253@localhost:5432/my_database` - `postgres://me:p%C3%A1ssword!%3Dwith_symbols@localhost:5432/my_database` -If the password is not encoded correctly, the driver will try and pass the raw -password to the database, however it's highly recommended that all passwords are -always encoded to prevent authentication errors +If the password is not encoded correctly, the driver will try to pass the raw +password to the database, however, it's highly recommended that all passwords +are always encoded to prevent authentication errors ### Database reconnection It's a very common occurrence to get broken connections due to connectivity -issues or OS related problems, however while this may be a minor inconvenience +issues or OS-related problems; however, while this may be a minor inconvenience in development, it becomes a serious matter in a production environment if not handled correctly. To mitigate the impact of disconnected clients -`deno-postgres` allows the developer to stablish a new connection with the +`deno-postgres` allows the developer to establish a new connection with the database automatically before executing a query on a broken connection. To manage the number of reconnection attempts, adjust the `connection.attempts` @@ -174,8 +177,8 @@ try { await client.queryArray`SELECT 1`; ``` -If automatic reconnection is not desired, the developer can simply set the -number of attempts to zero and manage connection and reconnection manually +If automatic reconnection is not desired, the developer can set the number of +attempts to zero and manage connection and reconnection manually ```ts const client = new Client({ @@ -201,9 +204,9 @@ Your initial connection will also be affected by this setting in a slightly different manner than already active errored connections. If you fail to connect to your database in the first attempt, the client will keep trying to connect as many times as requested, meaning that if your attempt configuration is three, -your total first-connection-attempts will ammount to four. +your total first-connection-attempts will amount to four. -Additionally you can set an interval before each reconnection by using the +Additionally, you can set an interval before each reconnection by using the `interval` parameter. This can be either a plane number or a function where the developer receives the previous interval and returns the new one, making it easy to implement exponential backoff (Note: the initial interval for this function @@ -230,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 @@ -304,7 +304,7 @@ const client = new Client( ); ``` -Additionally you can specify the host using the `host` URL parameter +Additionally, you can specify the host using the `host` URL parameter ```ts const client = new Client( @@ -324,15 +324,15 @@ terminate the connection or to attempt to connect using a non-encrypted one. This behavior can be defined using the connection parameter `tls.enforce` or the "required" option when using a connection string. -If set, the driver will fail inmediately if no TLS connection can be -established, otherwise the driver will attempt to connect without encryption -after TLS connection has failed, but will display a warning containing the +If set, the driver will fail immediately if no TLS connection can be +established, otherwise, the driver will attempt to connect without encryption +after the TLS connection has failed, but will display a warning containing the reason why the TLS connection failed. **This is the default configuration**. If you wish to skip TLS connections altogether, you can do so by passing false as a parameter in the `tls.enabled` option or the "disable" option when using a connection string. Although discouraged, this option is pretty useful when -dealing with development databases or versions of Postgres that didn't support +dealing with development databases or versions of Postgres that don't support TLS encrypted connections. #### About invalid and custom TLS certificates @@ -341,9 +341,9 @@ There is a myriad of factors you have to take into account when using a 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) +When using a self-signed certificate, make sure to specify the PEM encoded CA +certificate using the `--cert` option when starting Deno or in the +`tls.caCertificates` option when creating a client ```ts const client = new Client({ @@ -364,14 +364,14 @@ const client = new Client({ ``` TLS can be disabled from your server by editing your `postgresql.conf` file and -setting the `ssl` option to `off`, or in the driver side by using the "disabled" +setting the `ssl` option to `off`, or on the driver side by using the "disabled" option in the client configuration. ### Env parameters The values required to connect to the database can be read directly from environmental variables, given the case that the user doesn't provide them while -initializing the client. The only requirement for this variables to be read is +initializing the client. The only requirement for these variables to be read is for Deno to be run with `--allow-env` permissions The env variables that the client will recognize are taken from `libpq` to keep @@ -380,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(); @@ -390,9 +390,9 @@ await client.end(); ## Connection Client Clients are the most basic block for establishing communication with your -database. They provide abstractions over queries, transactions and connection +database. They provide abstractions over queries, transactions, and connection management. In `deno-postgres`, similar clients such as the transaction and pool -client inherit it's functionality from the basic client, so the available +client inherit their functionality from the basic client, so the available methods will be very similar across implementations. You can create a new client by providing the required connection parameters: @@ -426,7 +426,7 @@ await client_1.end(); await client_2.end(); ``` -Ending a client will cause it to destroy it's connection with the database, +Ending a client will cause it to destroy its connection with the database, forcing you to reconnect in order to execute operations again. In Postgres, connections are a synonym for session, which means that temporal operations such as the creation of temporal tables or the use of the `PG_TEMP` schema will not @@ -438,17 +438,23 @@ For stronger management and scalability, you can use **pools**: ```ts const POOL_CONNECTIONS = 20; -const dbPool = new Pool({ - database: "database", - hostname: "hostname", - password: "password", - port: 5432, - user: "user", -}, POOL_CONNECTIONS); +const dbPool = new Pool( + { + database: "database", + hostname: "hostname", + password: "password", + port: 5432, + user: "user", + }, + 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 @@ -511,9 +517,9 @@ await client_3.release(); #### Pools made simple -The following example is a simple abstraction over pools that allow 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) { @@ -528,14 +534,34 @@ 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 Executing a query is as simple as providing the raw SQL to your client, it will -automatically be queued, validated and processed so you can get a human -readable, blazing fast result +automatically be queued, validated, and processed so you can get a human +readable, blazing-fast result ```ts const result = await client.queryArray("SELECT ID, NAME FROM PEOPLE"); @@ -548,7 +574,7 @@ Prepared statements are a Postgres mechanism designed to prevent SQL injection and maximize query performance for multiple queries (see https://security.stackexchange.com/questions/15214/are-prepared-statements-100-safe-against-sql-injection) -The idea is simple, provide a base sql statement with placeholders for any +The idea is simple, provide a base SQL statement with placeholders for any variables required, and then provide said variables in an array of arguments ```ts @@ -593,9 +619,9 @@ replaced at runtime with an argument object } ``` -Behind the scenes, `deno-postgres` will replace the variables names in your -query for Postgres-readable placeholders making it easy to reuse values in -multiple places in your query +Behind the scenes, `deno-postgres` will replace the variable names in your query +for Postgres-readable placeholders making it easy to reuse values in multiple +places in your query ```ts { @@ -622,7 +648,7 @@ arguments object #### Template strings -Even thought the previous call is already pretty simple, it can be simplified +Even though the previous call is already pretty simple, it can be simplified even further by the use of template strings, offering all the benefits of prepared statements with a nice and clear syntax for your queries @@ -644,16 +670,16 @@ prepared statements with a nice and clear syntax for your queries Obviously, you can't pass any parameters provided by the `QueryOptions` interface such as explicitly named fields, so this API is best used when you -have a straight forward statement that only requires arguments to work as +have a straightforward statement that only requires arguments to work as intended -#### Regarding non argument parameters +#### Regarding non-argument parameters -A common assumption many people do when working with prepared statements is that -they work the same way string interpolation works, by replacing the placeholders -with whatever variables have been passed down to the query. However the reality -is a little more complicated than that where only very specific parts of a query -can use placeholders to indicate upcoming values +A common assumption many people make when working with prepared statements is +that they work the same way string interpolation works, by replacing the +placeholders with whatever variables have been passed down to the query. However +the reality is a little more complicated than that where only very specific +parts of a query can use placeholders to indicate upcoming values That's the reason why the following works @@ -672,7 +698,7 @@ SELECT MY_DATA FROM $1 Specifically, you can't replace any keyword or specifier in a query, only literal values, such as the ones you would use in an `INSERT` or `WHERE` clause -This is specially hard to grasp when working with template strings, since the +This is especially hard to grasp when working with template strings, since the assumption that is made most of the time is that all items inside a template string call are being interpolated with the underlying string, however as explained above this is not the case, so all previous warnings about prepared @@ -683,18 +709,143 @@ statements apply here as well const my_id = 17; await client.queryArray`UPDATE TABLE X SET Y = 0 WHERE Z = ${my_id}`; -// Invalid attempt to replace an specifier +// Invalid attempt to replace a specifier const my_table = "IMPORTANT_TABLE"; const my_other_id = 41; await client .queryArray`DELETE FROM ${my_table} WHERE MY_COLUMN = ${my_other_id};`; ``` +### Result decoding + +When a query is executed, the database returns all the data serialized as string +values. The `deno-postgres` driver automatically takes care of decoding the +results data of your query into the closest JavaScript compatible data type. +This makes it easy to work with the data in your application using native +JavaScript types. A list of implemented type parsers can be found +[here](https://github.com/denodrivers/postgres/issues/446). + +However, you may have more specific needs or may want to handle decoding +yourself in your application. The driver provides two ways to handle decoding of +the result data: + +#### Decode strategy + +You can provide a global decode strategy to the client that will be used to +decode the result data. This can be done by setting the `decodeStrategy` +controls option when creating your query client. The following options are +available: + +- `auto`: (**default**) values are parsed to JavaScript types or objects + (non-implemented type parsers would still return strings). +- `string`: all values are returned as string, and the user has to take care of + parsing + +```ts +{ + // Will return all values parsed to native types + const client = new Client({ + database: "some_db", + user: "some_user", + controls: { + decodeStrategy: "auto", // or not setting it at all + }, + }); + + const result = await client.queryArray( + "SELECT ID, NAME, AGE, BIRTHDATE FROM PEOPLE WHERE ID = 1", + ); + console.log(result.rows); // [[1, "Laura", 25, Date('1996-01-01') ]] + + // versus + + // Will return all values as strings + const client = new Client({ + database: "some_db", + user: "some_user", + controls: { + decodeStrategy: "string", + }, + }); + + const result = await client.queryArray( + "SELECT ID, NAME, AGE, BIRTHDATE FROM PEOPLE WHERE ID = 1", + ); + console.log(result.rows); // [["1", "Laura", "25", "1996-01-01"]] +} +``` + +#### Custom decoders + +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. + +You can use it with the decode strategy. Custom decoders take precedence over +the strategy and internal decoders. + +```ts +{ + // Will return all values as strings, but custom decoders will take precedence + const client = new Client({ + database: "some_db", + user: "some_user", + controls: { + decodeStrategy: "string", + decoders: { + // Custom decoder for boolean + // for some reason, return booleans as an object with a type and value + bool: (value: string) => ({ + value: value === "t", + type: "boolean", + }), + }, + }, + }); + + 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"}} +} +``` + +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 } +} +``` + ### Specifying result type Both the `queryArray` and `queryObject` functions have a generic implementation that allows users to type the result of the executed query to obtain -intellisense +IntelliSense ```ts { @@ -722,9 +873,10 @@ intellisense } { - const object_result = await client.queryObject< - { id: number; name: string } - >`SELECT ID, NAME FROM PEOPLE WHERE ID = ${17}`; + const object_result = await client.queryObject<{ + id: number; + name: string; + }>`SELECT ID, NAME FROM PEOPLE WHERE ID = ${17}`; // {id: number, name: string} const person = object_result.rows[0]; } @@ -741,9 +893,7 @@ interface User { name: string; } -const result = await client.queryObject( - "SELECT ID, NAME FROM PEOPLE", -); +const result = await client.queryObject("SELECT ID, NAME FROM PEOPLE"); // User[] const users = result.rows; @@ -751,21 +901,21 @@ const users = result.rows; #### Case transformation -When consuming a database, specially one not managed by themselves but a +When consuming a database, especially one not managed by themselves but a external one, many developers have to face different naming standards that may disrupt the consistency of their codebase. And while there are simple solutions for that such as aliasing every query field that is done to the database, one -easyb built-in solution allows developers to transform the incoming query names +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", }); @@ -791,17 +941,15 @@ To deal with this issue, it's recommended to provide a field list that maps to the expected properties we want in the resulting object ```ts -const result = await client.queryObject( - { - text: "SELECT ID, SUBSTR(NAME, 0, 2) FROM PEOPLE", - fields: ["id", "name"], - }, -); +const result = await client.queryObject({ + text: "SELECT ID, SUBSTR(NAME, 0, 2) FROM PEOPLE", + fields: ["id", "name"], +}); const users = result.rows; // [{id: 1, name: 'Ca'}, {id: 2, name: 'Jo'}, ...] ``` -**Don't use TypeScript generics to map these properties**, this generics only +**Don't use TypeScript generics to map these properties**, these generics only exist at compile time and won't affect the final outcome of the query ```ts @@ -833,23 +981,19 @@ Other aspects to take into account when using the `fields` argument: ```ts { // This will throw because the property id is duplicated - await client.queryObject( - { - text: "SELECT ID, SUBSTR(NAME, 0, 2) FROM PEOPLE", - fields: ["id", "ID"], - }, - ); + await client.queryObject({ + text: "SELECT ID, SUBSTR(NAME, 0, 2) FROM PEOPLE", + fields: ["id", "ID"], + }); } { - // This will throw because the returned number of columns don't match the + // This will throw because the returned number of columns doesn't match the // number of defined ones in the function call - await client.queryObject( - { - text: "SELECT ID, SUBSTR(NAME, 0, 2) FROM PEOPLE", - fields: ["id", "name", "something_else"], - }, - ); + await client.queryObject({ + text: "SELECT ID, SUBSTR(NAME, 0, 2) FROM PEOPLE", + fields: ["id", "name", "something_else"], + }); } ``` @@ -857,8 +1001,8 @@ Other aspects to take into account when using the `fields` argument: A lot of effort was put into abstracting Transactions in the library, and the final result is an API that is both simple to use and offers all of the options -and features that you would get by executing SQL statements, plus and extra -layer of abstraction that helps you catch mistakes ahead of time. +and features that you would get by executing SQL statements, plus an extra layer +of abstraction that helps you catch mistakes ahead of time. #### Creating a transaction @@ -881,14 +1025,14 @@ await transaction.commit(); ##### Transaction locks -Due to how SQL transactions work, everytime you begin a transaction all queries +Due to how SQL transactions work, every time you begin a transaction all queries you do in your session will run inside that transaction context. This is a problem for query execution since it might cause queries that are meant to do persistent changes to the database to live inside this context, making them -susceptible to be rolled back unintentionally. We will call this kind of queries -**unsafe operations**. +susceptible to being rolled back unintentionally. We will call this kind of +queries **unsafe operations**. -Everytime you create a transaction the client you use will get a lock, with the +Every time you create a transaction the client you use will get a lock, with the purpose of blocking any external queries from running while a transaction takes course, effectively avoiding all unsafe operations. @@ -906,12 +1050,12 @@ await transaction.commit(); await client.queryArray`DELETE TABLE X`; ``` -For this very reason however, if you are using transactions in an application +For this very reason, however, if you are using transactions in an application with concurrent access like an API, it is recommended that you don't use the Client API at all. If you do so, the client will be blocked from executing other -queries until the transaction has finished. Instead of that, use a connection -pool, that way all your operations will be executed in a different context -without locking the main client. +queries until the transaction has finished. Instead, use a connection pool, that +way all your operations will be executed in a different context without locking +the main client. ```ts const client_1 = await pool.connect(); @@ -946,7 +1090,7 @@ SELECT ID FROM MY_TABLE; -- Will attempt to execute, but will fail cause transac COMMIT; -- Transaction will end, but no changes to MY_TABLE will be made ``` -However, due to how JavaScript works we can handle this kinds of errors in a +However, due to how JavaScript works we can handle these kinds of errors in a more fashionable way. All failed queries inside a transaction will automatically end it and release the main client. @@ -963,7 +1107,7 @@ function executeMyTransaction() { await transaction.queryArray`SELECT []`; // Error will be thrown, transaction will be aborted await transaction.queryArray`SELECT ID FROM MY_TABLE`; // Won't even attempt to execute - await transaction.commit(); // Don't even need it, transaction was already ended + await transaction.commit(); // Don't even need it, the transaction was already ended } catch (e) { return false; } @@ -972,26 +1116,26 @@ function executeMyTransaction() { } ``` -This limits only to database related errors though, regular errors won't end the +This limits only to database-related errors though, regular errors won't end the connection and may allow the user to execute a different code path. This is -specially good for ahead of time validation errors such as the ones found in the -rollback and savepoint features. +especially good for ahead-of-time validation errors such as the ones found in +the rollback and savepoint features. ```ts 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; } } @@ -1006,8 +1150,8 @@ await transaction.commit(); #### Transaction options PostgreSQL provides many options to customize the behavior of transactions, such -as isolation level, read modes and startup snapshot. All this options can be set -by passing a second argument to the `startTransaction` method +as isolation level, read modes, and startup snapshot. All these options can be +set by passing a second argument to the `startTransaction` method ```ts const transaction = client.createTransaction("ts_1", { @@ -1024,10 +1168,11 @@ place _after_ the transaction had begun. The following is a demonstration. A sensible transaction that loads a table with some very important test results and the students that passed said test. This is -a long running operation, and in the meanwhile someone is tasked to cleanup the -results from the tests table because it's taking too much space in the database. +a long-running operation, and in the meanwhile, someone is tasked to clean up +the results from the tests table because it's taking up too much space in the +database. -If the transaction were to be executed as it follows, the test results would be +If the transaction were to be executed as follows, the test results would be lost before the graduated students could be extracted from the original table, causing a mismatch in the data. @@ -1054,7 +1199,7 @@ await transaction.queryArray`INSERT INTO TEST_RESULTS // executes this query while the operation above still takes place await client_2.queryArray`DELETE FROM TESTS WHERE TEST_TYPE = 'final_test'`; -// Test information is gone, no data will be loaded into the graduated students table +// Test information is gone, and no data will be loaded into the graduated students table await transaction.queryArray`INSERT INTO GRADUATED_STUDENTS SELECT USER_ID @@ -1078,6 +1223,7 @@ following levels of transaction isolation: - 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 const client_1 = await pool.connect(); const client_2 = await pool.connect(); @@ -1089,18 +1235,18 @@ following levels of transaction isolation: await transaction.begin(); // This locks the current value of IMPORTANT_TABLE // Up to this point, all other external changes will be included - const { rows: query_1 } = await transaction.queryObject< - { password: string } - >`SELECT PASSWORD FROM IMPORTANT_TABLE WHERE ID = ${my_id}`; + const { rows: query_1 } = await transaction.queryObject<{ + password: string; + }>`SELECT PASSWORD FROM IMPORTANT_TABLE WHERE ID = ${my_id}`; const password_1 = rows[0].password; // Concurrent operation executed by a different user in a different part of the code await client_2 .queryArray`UPDATE IMPORTANT_TABLE SET PASSWORD = 'something_else' WHERE ID = ${the_same_id}`; - const { rows: query_2 } = await transaction.queryObject< - { password: string } - >`SELECT PASSWORD FROM IMPORTANT_TABLE WHERE ID = ${my_id}`; + const { rows: query_2 } = await transaction.queryObject<{ + password: string; + }>`SELECT PASSWORD FROM IMPORTANT_TABLE WHERE ID = ${my_id}`; const password_2 = rows[0].password; // Database state is not updated while the transaction is ongoing @@ -1114,9 +1260,10 @@ following levels of transaction isolation: ``` - Serializable: Just like the repeatable read mode, all external changes won't - be visible until the transaction has finished. However this also prevents the + be visible until the transaction has finished. However, this also 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 const client_1 = await pool.connect(); const client_2 = await pool.connect(); @@ -1128,9 +1275,9 @@ following levels of transaction isolation: await transaction.begin(); // This locks the current value of IMPORTANT_TABLE // Up to this point, all other external changes will be included - await transaction.queryObject< - { password: string } - >`SELECT PASSWORD FROM IMPORTANT_TABLE WHERE ID = ${my_id}`; + await transaction.queryObject<{ + password: string; + }>`SELECT PASSWORD FROM IMPORTANT_TABLE WHERE ID = ${my_id}`; // Concurrent operation executed by a different user in a different part of the code await client_2 @@ -1150,9 +1297,9 @@ following levels of transaction isolation: ##### Read modes -In many cases, and specially when allowing third parties to access data inside +In many cases, and especially when allowing third parties to access data inside your database it might be a good choice to prevent queries from modifying the -database in the course of the transaction. You can revoke this write privileges +database in the course of the transaction. You can revoke these write privileges by setting `read_only: true` in the transaction options. The default for all transactions will be to enable write permission. @@ -1263,7 +1410,8 @@ await transaction.rollback(savepoint); // Truncate gets undone ##### Rollback A rollback allows the user to end the transaction without persisting the changes -made to the database, preventing that way any unwanted operation to take place. +made to the database, preventing that way any unwanted operation from taking +place. ```ts const transaction = client.createTransaction("rolled_back_transaction"); @@ -1297,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 066d193f..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 f72d5d0d..13499468 100644 --- a/mod.ts +++ b/mod.ts @@ -5,18 +5,31 @@ export { TransactionError, } from "./client/error.ts"; export { Pool } from "./pool.ts"; - -// TODO -// Remove the following reexports after https://doc.deno.land -// supports two level depth exports +export { Oid, type OidType, OidTypes, type OidValue } from "./query/oid.ts"; export type { ClientOptions, ConnectionOptions, ConnectionString, + Decoders, + DecodeStrategy, TLSOptions, } from "./connection/connection_params.ts"; export type { Session } from "./client.ts"; +export type { Notice } from "./connection/message.ts"; export { PoolClient, QueryClient } from "./client.ts"; -export type { QueryObjectOptions, QueryOptions } from "./query/query.ts"; +export type { + CommandType, + QueryArguments, + QueryArrayResult, + QueryObjectOptions, + QueryObjectResult, + QueryOptions, + QueryResult, + ResultType, + RowDescription, +} from "./query/query.ts"; export { Savepoint, Transaction } from "./query/transaction.ts"; -export type { TransactionOptions } from "./query/transaction.ts"; +export type { + IsolationLevel, + TransactionOptions, +} from "./query/transaction.ts"; diff --git a/pool.ts b/pool.ts index 3488e799..16713d53 100644 --- a/pool.ts +++ b/pool.ts @@ -14,19 +14,19 @@ import { DeferredAccessStack } from "./utils/deferred.ts"; * with their PostgreSQL database * * ```ts - * import { Pool } from "./pool.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` @@ -35,8 +35,7 @@ import { DeferredAccessStack } from "./utils/deferred.ts"; * available connections in the pool * * ```ts - * import { Pool } from "./pool.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); @@ -55,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 { @@ -91,6 +91,9 @@ export class Pool { return this.#available_connections.size; } + /** + * A class that manages connection pooling for PostgreSQL clients + */ constructor( connection_params: ClientOptions | ConnectionString | undefined, size: number, @@ -116,12 +119,12 @@ export class Pool { * with the database if no other connections are available * * ```ts - * import { Pool } from "./pool.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,26 +141,29 @@ export class Pool { * This will close all open connections and set a terminated status in the pool * * ```ts - * import { Pool } from "./pool.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 "./pool.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 { @@ -183,21 +189,18 @@ export class Pool { */ async #initialize() { const initialized = this.#lazy ? 0 : this.#size; - const clients = Array.from( - { length: this.#size }, - async (_e, index) => { - const client: PoolClient = new PoolClient( - this.#connection_params, - () => this.#available_connections!.push(client), - ); - - if (index < initialized) { - await client.connect(); - } - - return client; - }, - ); + const clients = Array.from({ length: this.#size }, async (_e, index) => { + const client: PoolClient = new PoolClient( + this.#connection_params, + () => this.#available_connections!.push(client), + ); + + if (index < initialized) { + await client.connect(); + } + + return client; + }); this.#available_connections = new DeferredAccessStack( await Promise.all(clients), @@ -206,7 +209,8 @@ export class Pool { ); this.#ended = false; - } /** + } + /** * This will return the number of initialized clients in the pool */ diff --git a/query/array_parser.ts b/query/array_parser.ts index 1db591d0..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(); } @@ -34,13 +44,13 @@ class ArrayParser { const character = this.source[this.position++]; if (character === "\\") { return { - value: this.source[this.position++], escaped: true, + value: this.source[this.position++], }; } return { - value: character, escaped: false, + value: character, }; } @@ -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 8d61d34f..c0311910 100644 --- a/query/decode.ts +++ b/query/decode.ts @@ -1,4 +1,5 @@ -import { Oid } from "./oid.ts"; +import { Oid, type OidType, OidTypes, type OidValue } from "./oid.ts"; +import { bold, yellow } from "@std/fmt/colors"; import { decodeBigint, decodeBigintArray, @@ -14,6 +15,8 @@ import { decodeDateArray, decodeDatetime, decodeDatetimeArray, + decodeFloat, + decodeFloatArray, decodeInt, decodeIntArray, decodeJson, @@ -32,6 +35,8 @@ import { decodeTid, decodeTidArray, } from "./decoders.ts"; +import type { ClientControls } from "../connection/connection_params.ts"; +import { parseArray } from "./array_parser.ts"; export class Column { constructor( @@ -55,149 +60,199 @@ const decoder = new TextDecoder(); // TODO // Decode binary fields function decodeBinary() { - throw new Error("Not implemented!"); + throw new Error("Decoding binary data is not implemented!"); } -// deno-lint-ignore no-explicit-any -function decodeText(value: Uint8Array, typeOid: number): any { +function decodeText(value: string, typeOid: number) { + try { + switch (typeOid) { + case Oid.bpchar: + case Oid.char: + case Oid.cidr: + case Oid.float8: + case Oid.inet: + case Oid.macaddr: + case Oid.name: + case Oid.numeric: + case Oid.oid: + case Oid.regclass: + case Oid.regconfig: + case Oid.regdictionary: + case Oid.regnamespace: + case Oid.regoper: + case Oid.regoperator: + case Oid.regproc: + case Oid.regprocedure: + case Oid.regrole: + case Oid.regtype: + case Oid.text: + case Oid.time: + case Oid.timetz: + case Oid.uuid: + case Oid.varchar: + case Oid.void: + return value; + case Oid.bpchar_array: + case Oid.char_array: + case Oid.cidr_array: + case Oid.float8_array: + case Oid.inet_array: + case Oid.macaddr_array: + case Oid.name_array: + case Oid.numeric_array: + case Oid.oid_array: + case Oid.regclass_array: + case Oid.regconfig_array: + case Oid.regdictionary_array: + case Oid.regnamespace_array: + case Oid.regoper_array: + case Oid.regoperator_array: + case Oid.regproc_array: + case Oid.regprocedure_array: + case Oid.regrole_array: + case Oid.regtype_array: + case Oid.text_array: + case Oid.time_array: + case Oid.timetz_array: + case Oid.uuid_array: + case Oid.varchar_array: + return decodeStringArray(value); + case Oid.float4: + return decodeFloat(value); + case Oid.float4_array: + return decodeFloatArray(value); + case Oid.int2: + case Oid.int4: + case Oid.xid: + return decodeInt(value); + case Oid.int2_array: + case Oid.int4_array: + case Oid.xid_array: + return decodeIntArray(value); + case Oid.bool: + return decodeBoolean(value); + case Oid.bool_array: + return decodeBooleanArray(value); + case Oid.box: + return decodeBox(value); + case Oid.box_array: + return decodeBoxArray(value); + case Oid.circle: + return decodeCircle(value); + case Oid.circle_array: + return decodeCircleArray(value); + case Oid.bytea: + return decodeBytea(value); + case Oid.byte_array: + return decodeByteaArray(value); + case Oid.date: + return decodeDate(value); + case Oid.date_array: + return decodeDateArray(value); + case Oid.int8: + return decodeBigint(value); + case Oid.int8_array: + return decodeBigintArray(value); + case Oid.json: + case Oid.jsonb: + return decodeJson(value); + case Oid.json_array: + case Oid.jsonb_array: + return decodeJsonArray(value); + case Oid.line: + return decodeLine(value); + case Oid.line_array: + return decodeLineArray(value); + case Oid.lseg: + return decodeLineSegment(value); + case Oid.lseg_array: + return decodeLineSegmentArray(value); + case Oid.path: + return decodePath(value); + case Oid.path_array: + return decodePathArray(value); + case Oid.point: + return decodePoint(value); + case Oid.point_array: + return decodePointArray(value); + case Oid.polygon: + return decodePolygon(value); + case Oid.polygon_array: + return decodePolygonArray(value); + case Oid.tid: + return decodeTid(value); + case Oid.tid_array: + return decodeTidArray(value); + case Oid.timestamp: + case Oid.timestamptz: + return decodeDatetime(value); + case Oid.timestamp_array: + case Oid.timestamptz_array: + return decodeDatetimeArray(value); + default: + // A separate category for not handled values + // They might or might not be represented correctly as strings, + // returning them to the user as raw strings allows them to parse + // them as they see fit + return value; + } + } catch (e) { + console.error( + bold(yellow(`Error decoding type Oid ${typeOid} value`)) + + (e instanceof Error ? e.message : e) + + "\n" + + bold("Defaulting to null."), + ); + // If an error occurred during decoding, return null + return null; + } +} + +export function decode( + value: Uint8Array, + column: Column, + controls?: ClientControls, +) { const strValue = decoder.decode(value); - switch (typeOid) { - case Oid.bpchar: - case Oid.char: - case Oid.cidr: - case Oid.float4: - case Oid.float8: - case Oid.inet: - case Oid.macaddr: - case Oid.name: - case Oid.numeric: - case Oid.oid: - case Oid.regclass: - case Oid.regconfig: - case Oid.regdictionary: - case Oid.regnamespace: - case Oid.regoper: - case Oid.regoperator: - case Oid.regproc: - case Oid.regprocedure: - case Oid.regrole: - case Oid.regtype: - case Oid.text: - case Oid.time: - case Oid.timetz: - case Oid.uuid: - case Oid.varchar: - case Oid.void: - return strValue; - case Oid.bpchar_array: - case Oid.char_array: - case Oid.cidr_array: - case Oid.float4_array: - case Oid.float8_array: - case Oid.inet_array: - case Oid.macaddr_array: - case Oid.name_array: - case Oid.numeric_array: - case Oid.oid_array: - case Oid.regclass_array: - case Oid.regconfig_array: - case Oid.regdictionary_array: - case Oid.regnamespace_array: - case Oid.regoper_array: - case Oid.regoperator_array: - case Oid.regproc_array: - case Oid.regprocedure_array: - case Oid.regrole_array: - case Oid.regtype_array: - case Oid.text_array: - case Oid.time_array: - case Oid.timetz_array: - case Oid.uuid_array: - case Oid.varchar_array: - return decodeStringArray(strValue); - case Oid.int2: - case Oid.int4: - case Oid.xid: - return decodeInt(strValue); - case Oid.int2_array: - case Oid.int4_array: - case Oid.xid_array: - return decodeIntArray(strValue); - case Oid.bool: - return decodeBoolean(strValue); - case Oid.bool_array: - return decodeBooleanArray(strValue); - case Oid.box: - return decodeBox(strValue); - case Oid.box_array: - return decodeBoxArray(strValue); - case Oid.circle: - return decodeCircle(strValue); - case Oid.circle_array: - return decodeCircleArray(strValue); - case Oid.bytea: - return decodeBytea(strValue); - case Oid.byte_array: - return decodeByteaArray(strValue); - case Oid.date: - return decodeDate(strValue); - case Oid.date_array: - return decodeDateArray(strValue); - case Oid.int8: - return decodeBigint(strValue); - case Oid.int8_array: - return decodeBigintArray(strValue); - case Oid.json: - case Oid.jsonb: - return decodeJson(strValue); - case Oid.json_array: - case Oid.jsonb_array: - return decodeJsonArray(strValue); - case Oid.line: - return decodeLine(strValue); - case Oid.line_array: - return decodeLineArray(strValue); - case Oid.lseg: - return decodeLineSegment(strValue); - case Oid.lseg_array: - return decodeLineSegmentArray(strValue); - case Oid.path: - return decodePath(strValue); - case Oid.path_array: - return decodePathArray(strValue); - case Oid.point: - return decodePoint(strValue); - case Oid.point_array: - return decodePointArray(strValue); - case Oid.polygon: - return decodePolygon(strValue); - case Oid.polygon_array: - return decodePolygonArray(strValue); - case Oid.tid: - return decodeTid(strValue); - case Oid.tid_array: - return decodeTidArray(strValue); - case Oid.timestamp: - case Oid.timestamptz: - return decodeDatetime(strValue); - case Oid.timestamp_array: - case Oid.timestamptz_array: - return decodeDatetimeArray(strValue); - default: - // A separate category for not handled values - // They might or might not be represented correctly as strings, - // returning them to the user as raw strings allows them to parse - // them as they see fit - return strValue; + // 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?.[oidType]; + + if (decoderFunc) { + 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), + ); + } + } + } + } + + // check if the decode strategy is `string` + if (controls?.decodeStrategy === "string") { + return strValue; } -} -export function decode(value: Uint8Array, column: Column) { + // else, default to 'auto' mode, which uses the typeOid to determine the decoding strategy if (column.format === Format.BINARY) { return decodeBinary(); } else if (column.format === Format.TEXT) { - return decodeText(value, column.typeOid); + return decodeText(strValue, column.typeOid); } else { throw new Error(`Unknown column format: ${column.format}`); } diff --git a/query/decoders.ts b/query/decoders.ts index c5435836..58356d76 100644 --- a/query/decoders.ts +++ b/query/decoders.ts @@ -1,4 +1,3 @@ -import { date } from "../deps.ts"; import { parseArray } from "./array_parser.ts"; import type { Box, @@ -28,24 +27,46 @@ export function decodeBigint(value: string): bigint { } export function decodeBigintArray(value: string) { - return parseArray(value, (x) => BigInt(x)); + return parseArray(value, decodeBigint); } export function decodeBoolean(value: string): boolean { - return value[0] === "t"; + const v = value.toLowerCase(); + return ( + v === "t" || + v === "true" || + v === "y" || + v === "yes" || + v === "on" || + v === "1" + ); } export function decodeBooleanArray(value: string) { - return parseArray(value, (x) => x[0] === "t"); + return parseArray(value, decodeBoolean); } export function decodeBox(value: string): Box { - const [a, b] = value.match(/\(.*?\)/g) || []; + const points = value.match(/\(.*?\)/g) || []; - return { - a: decodePoint(a || ""), - b: decodePoint(b), - }; + if (points.length !== 2) { + throw new Error( + `Invalid Box: "${value}". Box must have only 2 point, ${points.length} given.`, + ); + } + + const [a, b] = points; + + try { + return { + a: decodePoint(a), + b: decodePoint(b), + }; + } catch (e) { + throw new Error( + `Invalid Box: "${value}" : ${(e instanceof Error ? e.message : e)}`, + ); + } } export function decodeBoxArray(value: string) { @@ -60,7 +81,7 @@ export function decodeBytea(byteaStr: string): Uint8Array { } } -export function decodeByteaArray(value: string): unknown[] { +export function decodeByteaArray(value: string) { return parseArray(value, decodeBytea); } @@ -73,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; @@ -104,14 +125,26 @@ function decodeByteaHex(byteaStr: string): Uint8Array { } export function decodeCircle(value: string): Circle { - const [point, radius] = value.substring(1, value.length - 1).split( - /,(?![^(]*\))/, - ) as [string, Float8]; + const [point, radius] = value + .substring(1, value.length - 1) + .split(/,(?![^(]*\))/) as [string, Float8]; - return { - point: decodePoint(point), - radius: radius, - }; + if (Number.isNaN(parseFloat(radius))) { + throw new Error( + `Invalid Circle: "${value}". Circle radius "${radius}" must be a valid number.`, + ); + } + + try { + return { + point: decodePoint(point), + radius: radius, + }; + } catch (e) { + throw new Error( + `Invalid Circle: "${value}" : ${(e instanceof Error ? e.message : e)}`, + ); + } } export function decodeCircleArray(value: string) { @@ -127,7 +160,7 @@ export function decodeDate(dateStr: string): Date | number { return Number(-Infinity); } - return date.parse(dateStr, "yyyy-MM-dd"); + return new Date(dateStr); } export function decodeDateArray(value: string) { @@ -186,12 +219,18 @@ export function decodeInt(value: string): number { return parseInt(value, 10); } -// deno-lint-ignore no-explicit-any -export function decodeIntArray(value: string): any { - if (!value) return null; +export function decodeIntArray(value: string) { return parseArray(value, decodeInt); } +export function decodeFloat(value: string): number { + return parseFloat(value); +} + +export function decodeFloatArray(value: string) { + return parseArray(value, decodeFloat); +} + export function decodeJson(value: string): unknown { return JSON.parse(value); } @@ -201,12 +240,28 @@ export function decodeJsonArray(value: string): unknown[] { } export function decodeLine(value: string): Line { - const [a, b, c] = value.substring(1, value.length - 1).split(",") as [ + const equationConsts = value.substring(1, value.length - 1).split(",") as [ Float8, Float8, Float8, ]; + if (equationConsts.length !== 3) { + throw new Error( + `Invalid Line: "${value}". Line in linear equation format must have 3 constants, ${equationConsts.length} given.`, + ); + } + + 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; + return { a: a, b: b, @@ -219,14 +274,28 @@ export function decodeLineArray(value: string) { } export function decodeLineSegment(value: string): LineSegment { - const [a, b] = value - .substring(1, value.length - 1) - .match(/\(.*?\)/g) || []; + const points = value.substring(1, value.length - 1).match(/\(.*?\)/g) || []; - return { - a: decodePoint(a || ""), - b: decodePoint(b), - }; + if (points.length !== 2) { + throw new Error( + `Invalid Line Segment: "${value}". Line segments must have only 2 point, ${points.length} given.`, + ); + } + + const [a, b] = points; + + try { + return { + a: decodePoint(a), + b: decodePoint(b), + }; + } catch (e) { + throw new Error( + `Invalid Line Segment: "${value}" : ${(e instanceof Error + ? e.message + : e)}`, + ); + } } export function decodeLineSegmentArray(value: string) { @@ -238,7 +307,15 @@ export function decodePath(value: string): Path { // since encapsulated commas are separators for the point coordinates const points = value.substring(1, value.length - 1).split(/,(?![^(]*\))/); - return points.map(decodePoint); + return points.map((point) => { + try { + return decodePoint(point); + } catch (e) { + throw new Error( + `Invalid Path: "${value}" : ${(e instanceof Error ? e.message : e)}`, + ); + } + }); } export function decodePathArray(value: string) { @@ -246,14 +323,23 @@ export function decodePathArray(value: string) { } export function decodePoint(value: string): Point { - const [x, y] = value.substring(1, value.length - 1).split(",") as [ - Float8, - Float8, - ]; + const coordinates = value + .substring(1, value.length - 1) + .split(",") as Float8[]; + + if (coordinates.length !== 2) { + throw new Error( + `Invalid Point: "${value}". Points must have only 2 coordinates, ${coordinates.length} given.`, + ); + } + + const [x, y] = coordinates; if (Number.isNaN(parseFloat(x)) || Number.isNaN(parseFloat(y))) { throw new Error( - `Invalid point value: "${Number.isNaN(parseFloat(x)) ? x : y}"`, + `Invalid Point: "${value}". Coordinate "${ + Number.isNaN(parseFloat(x)) ? x : y + }" must be a valid number.`, ); } @@ -268,7 +354,13 @@ export function decodePointArray(value: string) { } export function decodePolygon(value: string): Polygon { - return decodePath(value); + try { + return decodePath(value); + } catch (e) { + throw new Error( + `Invalid Polygon: "${value}" : ${(e instanceof Error ? e.message : e)}`, + ); + } } export function decodePolygonArray(value: string) { diff --git a/query/encode.ts b/query/encode.ts index 66866e4f..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; @@ -80,20 +79,29 @@ function encodeBytes(value: Uint8Array): string { return `\\x${hex}`; } +/** + * Types of a query arguments data encoded for execution + */ export type EncodedArg = null | string | Uint8Array; +/** + * Encode (serialize) a value that can be used in a query execution. + */ 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 29fc63e5..93c03ec2 100644 --- a/query/oid.ts +++ b/query/oid.ts @@ -1,3 +1,11 @@ +/** 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]; + +/** + * A map of OidType to OidValue. + */ export const Oid = { bool: 16, bytea: 17, @@ -166,4 +174,179 @@ export const Oid = { regnamespace_array: 4090, regrole: 4096, regrole_array: 4097, -}; +} as const; + +/** + * A map of OidValue to OidType. Used to decode values and avoid search iteration. + */ +export const OidTypes: { + [key in OidValue]: OidType; +} = { + 16: "bool", + 17: "bytea", + 18: "char", + 19: "name", + 20: "int8", + 21: "int2", + 22: "_int2vector_0", + 23: "int4", + 24: "regproc", + 25: "text", + 26: "oid", + 27: "tid", + 28: "xid", + 29: "_cid_0", + 30: "_oidvector_0", + 32: "_pg_ddl_command", + 71: "_pg_type", + 75: "_pg_attribute", + 81: "_pg_proc", + 83: "_pg_class", + 114: "json", + 142: "_xml_0", + 143: "_xml_1", + 194: "_pg_node_tree", + 199: "json_array", + 210: "_smgr", + 325: "_index_am_handler", + 600: "point", + 601: "lseg", + 602: "path", + 603: "box", + 604: "polygon", + 628: "line", + 629: "line_array", + 650: "cidr", + 651: "cidr_array", + 700: "float4", + 701: "float8", + 702: "_abstime_0", + 703: "_reltime_0", + 704: "_tinterval_0", + 705: "_unknown", + 718: "circle", + 719: "circle_array", + 790: "_money_0", + 791: "_money_1", + 829: "macaddr", + 869: "inet", + 1000: "bool_array", + 1001: "byte_array", + 1002: "char_array", + 1003: "name_array", + 1005: "int2_array", + 1006: "_int2vector_1", + 1007: "int4_array", + 1008: "regproc_array", + 1009: "text_array", + 1010: "tid_array", + 1011: "xid_array", + 1012: "_cid_1", + 1013: "_oidvector_1", + 1014: "bpchar_array", + 1015: "varchar_array", + 1016: "int8_array", + 1017: "point_array", + 1018: "lseg_array", + 1019: "path_array", + 1020: "box_array", + 1021: "float4_array", + 1022: "float8_array", + 1023: "_abstime_1", + 1024: "_reltime_1", + 1025: "_tinterval_1", + 1027: "polygon_array", + 1028: "oid_array", + 1033: "_aclitem_0", + 1034: "_aclitem_1", + 1040: "macaddr_array", + 1041: "inet_array", + 1042: "bpchar", + 1043: "varchar", + 1082: "date", + 1083: "time", + 1114: "timestamp", + 1115: "timestamp_array", + 1182: "date_array", + 1183: "time_array", + 1184: "timestamptz", + 1185: "timestamptz_array", + 1186: "_interval_0", + 1187: "_interval_1", + 1231: "numeric_array", + 1248: "_pg_database", + 1263: "_cstring_0", + 1266: "timetz", + 1270: "timetz_array", + 1560: "_bit_0", + 1561: "_bit_1", + 1562: "_varbit_0", + 1563: "_varbit_1", + 1700: "numeric", + 1790: "_refcursor_0", + 2201: "_refcursor_1", + 2202: "regprocedure", + 2203: "regoper", + 2204: "regoperator", + 2205: "regclass", + 2206: "regtype", + 2207: "regprocedure_array", + 2208: "regoper_array", + 2209: "regoperator_array", + 2210: "regclass_array", + 2211: "regtype_array", + 2249: "_record_0", + 2275: "_cstring_1", + 2276: "_any", + 2277: "_anyarray", + 2278: "void", + 2279: "_trigger", + 2280: "_language_handler", + 2281: "_internal", + 2282: "_opaque", + 2283: "_anyelement", + 2287: "_record_1", + 2776: "_anynonarray", + 2842: "_pg_authid", + 2843: "_pg_auth_members", + 2949: "_txid_snapshot_0", + 2950: "uuid", + 2951: "uuid_array", + 2970: "_txid_snapshot_1", + 3115: "_fdw_handler", + 3220: "_pg_lsn_0", + 3221: "_pg_lsn_1", + 3310: "_tsm_handler", + 3500: "_anyenum", + 3614: "_tsvector_0", + 3615: "_tsquery_0", + 3642: "_gtsvector_0", + 3643: "_tsvector_1", + 3644: "_gtsvector_1", + 3645: "_tsquery_1", + 3734: "regconfig", + 3735: "regconfig_array", + 3769: "regdictionary", + 3770: "regdictionary_array", + 3802: "jsonb", + 3807: "jsonb_array", + 3831: "_anyrange", + 3838: "_event_trigger", + 3904: "_int4range_0", + 3905: "_int4range_1", + 3906: "_numrange_0", + 3907: "_numrange_1", + 3908: "_tsrange_0", + 3909: "_tsrange_1", + 3910: "_tstzrange_0", + 3911: "_tstzrange_1", + 3912: "_daterange_0", + 3913: "_daterange_1", + 3926: "_int8range_0", + 3927: "_int8range_1", + 4066: "_pg_shseclabel", + 4089: "regnamespace", + 4090: "regnamespace_array", + 4096: "regrole", + 4097: "regrole_array", +} as const; diff --git a/query/query.ts b/query/query.ts index e58aa85a..bdf0276e 100644 --- a/query/query.ts +++ b/query/query.ts @@ -1,6 +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 { Notice } from "../connection/message.ts"; +import type { ClientControls } from "../connection/connection_params.ts"; // TODO // Limit the type of parameters that can be passed @@ -14,35 +15,42 @@ import { type Notice } from "../connection/message.ts"; * They will take the position according to the order in which they were provided * * ```ts - * import { Client } from "../client.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(); * ``` */ + +/** Types of arguments passed to a query */ export type QueryArguments = unknown[] | Record; const commandTagRegexp = /^([A-Za-z]+)(?: (\d+))?(?: (\d+))?/; -type CommandType = +/** Type of query to be executed */ +export type CommandType = | "INSERT" | "DELETE" | "UPDATE" | "SELECT" | "MOVE" | "FETCH" - | "COPY"; + | "COPY" + | "CREATE"; +/** Type of a query result */ export enum ResultType { ARRAY, OBJECT, } +/** Class to describe a row */ export class RowDescription { + /** Create a new row description */ constructor(public columnCount: number, public columns: Column[]) {} } @@ -95,9 +103,7 @@ function normalizeObjectQueryArgs( args: Record, ): Record { const normalized_args = Object.fromEntries( - Object.entries(args).map(( - [key, value], - ) => [key.toLowerCase(), value]), + Object.entries(args).map(([key, value]) => [key.toLowerCase(), value]), ); if (Object.keys(normalized_args).length !== Object.keys(args).length) { @@ -109,49 +115,71 @@ function normalizeObjectQueryArgs( return normalized_args; } +/** Types of options */ export interface QueryOptions { + /** The arguments to be passed to the query */ args?: QueryArguments; + /** A custom function to override the encoding logic of the arguments passed to the query */ encoder?: (arg: unknown) => EncodedArg; + /**The name of the query statement */ name?: string; // TODO // Rename to query + /** The query statement to be executed */ text: string; } +/** Options to control the behavior of a Query instance */ 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" }` */ fields?: string[]; } -export class QueryResult { +/** + * This class is used to handle the result of a query + */ +export abstract class QueryResult { + /** + * Type of query executed for this result + */ public command!: CommandType; + /** + * The amount of rows affected by the query + */ + // TODO change to affectedRows public rowCount?: number; /** * This variable will be set after the class initialization, however it's required to be set * in order to handle result rows coming in */ #row_description?: RowDescription; + /** + * The warnings of the result + */ public warnings: Notice[] = []; - get rowDescription() { + /** + * The row description of the result + */ + get rowDescription(): RowDescription | undefined { return this.#row_description; } @@ -162,6 +190,9 @@ export class QueryResult { } } + /** + * Create a query result instance for the query passed + */ constructor(public query: Query) {} /** @@ -172,6 +203,9 @@ export class QueryResult { this.rowDescription = description; } + /** + * Handles the command complete message + */ handleCommandComplete(commandTag: string): void { const match = commandTagRegexp.exec(commandTag); if (match) { @@ -192,16 +226,24 @@ 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; } -export class QueryArrayResult = Array> - extends QueryResult { +/** + * This class is used to handle the result of a query that returns an array + */ +export class QueryArrayResult< + T extends Array = Array, +> extends QueryResult { + /** + * The result rows + */ public rows: T[] = []; - insertRow(row_data: Uint8Array[]) { + /** + * Insert a row into the result + */ + insertRow(row_data: Uint8Array[], controls?: ClientControls) { if (!this.rowDescription) { throw new Error( "The row descriptions required to parse the result data weren't initialized", @@ -215,7 +257,7 @@ export class QueryArrayResult = Array> if (raw_value === null) { return null; } - return decode(raw_value, column); + return decode(raw_value, column, controls); }) as T; this.rows.push(row); @@ -234,21 +276,19 @@ function findDuplicatesInArray(array: string[]): string[] { } function snakecaseToCamelcase(input: string) { - return input - .split("_") - .reduce( - (res, word, i) => { - if (i !== 0) { - word = word[0].toUpperCase() + word.slice(1); - } + return input.split("_").reduce((res, word, i) => { + if (i !== 0) { + word = word[0].toUpperCase() + word.slice(1); + } - res += word; - return res; - }, - "", - ); + res += word; + return res; + }, ""); } +/** + * This class is used to handle the result of a query that returns an object + */ export class QueryObjectResult< T = Record, > extends QueryResult { @@ -256,9 +296,15 @@ export class QueryObjectResult< * The column names will be undefined on the first run of insertRow, since */ public columns?: string[]; + /** + * The rows of the result + */ public rows: T[] = []; - insertRow(row_data: Uint8Array[]) { + /** + * Insert a row into the result + */ + insertRow(row_data: Uint8Array[], controls?: ClientControls) { if (!this.rowDescription) { throw new Error( "The row description required to parse the result data wasn't initialized", @@ -278,13 +324,13 @@ 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) ); } else { - column_names = this.rowDescription.columns.map((column) => - column.name + column_names = this.rowDescription.columns.map( + (column) => column.name, ); } @@ -293,7 +339,9 @@ export class QueryObjectResult< if (duplicates.length) { throw new Error( `Field names ${ - duplicates.map((str) => `"${str}"`).join(", ") + duplicates + .map((str) => `"${str}"`) + .join(", ") } are duplicated in the result of the query`, ); } @@ -311,28 +359,28 @@ export class QueryObjectResult< ); } - const row = row_data.reduce( - (row, raw_value, index) => { - const current_column = this.rowDescription!.columns[index]; + const row = row_data.reduce((row, raw_value, index) => { + const current_column = this.rowDescription!.columns[index]; - if (raw_value === null) { - row[columns[index]] = null; - } else { - row[columns[index]] = decode(raw_value, current_column); - } + if (raw_value === null) { + row[columns[index]] = null; + } else { + row[columns[index]] = decode(raw_value, current_column, controls); + } - return row; - }, - {} as Record, - ); + return row; + }, {} as Record); this.rows.push(row as T); } } +/** + * This class is used to handle the query to be executed by the database + */ 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 @@ -360,15 +408,8 @@ export class Query { this.text = config_or_text; this.args = args.map(encodeArgument); } else { - let { - args = [], - camelcase, - encoder = encodeArgument, - fields, - // deno-lint-ignore no-unused-vars - name, - text, - } = 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 // the result of the query @@ -391,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 a5088cfd..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, @@ -13,6 +13,22 @@ import { import { isTemplateString } from "../utils/utils.ts"; import { PostgresError, TransactionError } from "../client/error.ts"; +/** The isolation level of a transaction to control how we determine the data integrity between transactions */ +export type IsolationLevel = + | "read_committed" + | "repeatable_read" + | "serializable"; + +/** Type of the transaction options */ +export type TransactionOptions = { + isolation_level?: IsolationLevel; + read_only?: boolean; + snapshot?: string; +}; + +/** + * A savepoint is a point in a transaction that you can roll back to + */ export class Savepoint { /** * This is the count of the current savepoint instances in the transaction @@ -21,6 +37,9 @@ export class Savepoint { #release_callback: (name: string) => Promise; #update_callback: (name: string) => Promise; + /** + * Create a new savepoint with the provided name and callbacks + */ constructor( public readonly name: string, update_callback: (name: string) => Promise, @@ -30,7 +49,10 @@ export class Savepoint { this.#update_callback = update_callback; } - get instances() { + /** + * This is the count of the current savepoint instances in the transaction + */ + get instances(): number { return this.#instance_count; } @@ -38,28 +60,36 @@ export class Savepoint { * Releasing a savepoint will remove it's last instance in the transaction * * ```ts - * import { Client } from "../client.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 "../client.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 @@ -77,31 +107,33 @@ export class Savepoint { * Updating a savepoint will update its position in the transaction execution * * ```ts - * import { Client } from "../client.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 "../client.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() { @@ -110,23 +142,27 @@ export class Savepoint { } } -type IsolationLevel = "read_committed" | "repeatable_read" | "serializable"; - -export type TransactionOptions = { - isolation_level?: IsolationLevel; - read_only?: boolean; - snapshot?: string; -}; - +/** + * A transaction class + * + * Transactions are a powerful feature that guarantees safe operations by allowing you to control + * the outcome of a series of statements and undo, reset, and step back said operations to + * your liking + */ export class Transaction { #client: QueryClient; #executeQuery: (query: Query) => Promise; + /** The isolation level of the transaction */ #isolation_level: IsolationLevel; #read_only: boolean; + /** The transaction savepoints */ #savepoints: Savepoint[] = []; #snapshot?: string; #updateClientLock: (name: string | null) => void; + /** + * Create a new transaction with the provided name and options + */ constructor( public name: string, options: TransactionOptions | undefined, @@ -142,11 +178,17 @@ export class Transaction { this.#updateClientLock = update_client_lock_callback; } - get isolation_level() { + /** + * Get the isolation level of the transaction + */ + get isolation_level(): IsolationLevel { return this.#isolation_level; } - get savepoints() { + /** + * Get all the savepoints of the transaction + */ + get savepoints(): Savepoint[] { return this.#savepoints; } @@ -156,7 +198,7 @@ export class Transaction { #assertTransactionOpen() { if (this.#client.session.current_transaction !== this.name) { throw new Error( - `This transaction has not been started yet, make sure to use the "begin" method to do so`, + 'This transaction has not been started yet, make sure to use the "begin" method to do so', ); } } @@ -169,23 +211,21 @@ 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 "../client.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 */ async begin() { if (this.#client.session.current_transaction !== null) { if (this.#client.session.current_transaction === this.name) { - throw new Error( - "This transaction is already open", - ); + throw new Error("This transaction is already open"); } throw new Error( @@ -232,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); @@ -248,29 +287,31 @@ export class Transaction { * current transaction and end the current transaction * * ```ts - * import { Client } from "../client.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 "../client.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 @@ -289,9 +330,8 @@ export class Transaction { } catch (e) { if (e instanceof PostgresError) { throw new TransactionError(this.name, e); - } else { - throw e; } + throw e; } } @@ -323,24 +363,28 @@ export class Transaction { * the snapshot state between two transactions * * ```ts - * import { Client } from "../client.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 */ async getSnapshot(): Promise { this.#assertTransactionOpen(); - const { rows } = await this.queryObject< - { snapshot: string } - >`SELECT PG_EXPORT_SNAPSHOT() AS SNAPSHOT;`; + const { rows } = await this.queryObject<{ + snapshot: string; + }>`SELECT PG_EXPORT_SNAPSHOT() AS SNAPSHOT;`; return rows[0].snapshot; } @@ -349,48 +393,84 @@ export class Transaction { * It supports a generic interface in order to type the entries retrieved by the query * * ```ts - * import { Client } from "../client.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 "../client.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 "../client.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>( query: string, args?: QueryArguments, ): Promise>; + /** + * Use the configuration object for more advance options to execute the query + * + * ```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>( config: QueryOptions, ): Promise>; + /** + * Execute prepared statements with template strings + * + * ```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>( strings: TemplateStringsArray, ...args: unknown[] @@ -419,94 +499,79 @@ export class Transaction { } try { - return await this.#executeQuery(query) as QueryArrayResult; + return (await this.#executeQuery(query)) as QueryArrayResult; } catch (e) { if (e instanceof PostgresError) { await this.commit(); throw new TransactionError(this.name, e); - } else { - throw e; } + throw e; } } /** - * This method allows executed queries to be retrieved as object entries. - * It supports a generic interface in order to type the entries retrieved by the query + * 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 "../client.ts"; + * import { Client } from "jsr:@db/postgres"; + * const my_client = new Client(); * - * const client = new Client(); - * const transaction = client.createTransaction("transaction"); + * const { rows: rows1 } = await my_client.queryObject( + * "SELECT ID, NAME FROM CLIENTS" + * ); // Record * - * { - * const { rows } = await transaction.queryObject( - * "SELECT ID, NAME FROM CLIENTS" - * ); // Record - * } + * const { rows: rows2 } = await my_client.queryObject<{id: number, name: string}>( + * "SELECT ID, NAME FROM CLIENTS" + * ); // Array<{id: number, name: string}> * - * { - * const { rows } = await transaction.queryObject<{id: number, name: string}>( - * "SELECT ID, NAME FROM CLIENTS" - * ); // Array<{id: number, name: string}> - * } + * await my_client.end(); * ``` - * - * You can also map the expected results to object fields using the configuration interface. - * This will be assigned in the order they were provided + */ + async queryObject( + query: string, + args?: QueryArguments, + ): Promise>; + /** + * Use the configuration object for more advance options to execute the query * * ```ts - * import { Client } from "../client.ts"; + * import { Client } from "jsr:@db/postgres"; + * const my_client = new Client(); * - * const client = new Client(); - * const transaction = client.createTransaction("transaction"); + * const { rows: rows1 } = await my_client.queryObject( + * "SELECT ID, NAME FROM CLIENTS" + * ); + * console.log(rows1); // [{id: 78, name: "Frank"}, {id: 15, name: "Sarah"}] * - * { - * const { rows } = await transaction.queryObject( - * "SELECT ID, NAME FROM CLIENTS" - * ); + * const { rows: rows2 } = await my_client.queryObject({ + * text: "SELECT ID, NAME FROM CLIENTS", + * fields: ["personal_id", "complete_name"], + * }); + * console.log(rows2); // [{personal_id: 78, complete_name: "Frank"}, {personal_id: 15, complete_name: "Sarah"}] * - * console.log(rows); // [{id: 78, name: "Frank"}, {id: 15, name: "Sarah"}] - * } - * - * { - * const { rows } = await transaction.queryObject({ - * text: "SELECT ID, NAME FROM CLIENTS", - * fields: ["personal_id", "complete_name"], - * }); - * - * console.log(rows); // [{personal_id: 78, complete_name: "Frank"}, {personal_id: 15, complete_name: "Sarah"}] - * } + * await my_client.end(); * ``` - * - * It also allows you to execute prepared stamements with template strings + */ + async queryObject( + config: QueryObjectOptions, + ): Promise>; + /** + * Execute prepared statements with template strings * * ```ts - * import { Client } from "../client.ts"; - * - * const client = new Client(); - * const transaction = client.createTransaction("transaction"); - * + * import { Client } from "jsr:@db/postgres"; + * const my_client = new Client(); * const id = 12; * // Array<{id: number, name: string}> - * const {rows} = await transaction.queryObject<{id: number, name: string}>`SELECT ID, NAME FROM CLIENTS WHERE ID = ${id}`; + * const { rows } = await my_client.queryObject<{id: number, name: string}>`SELECT ID, NAME FROM CLIENTS WHERE ID = ${id}`; + * await my_client.end(); * ``` */ - async queryObject( - query: string, - args?: QueryArguments, - ): Promise>; - async queryObject( - config: QueryObjectOptions, - ): Promise>; async queryObject( query: TemplateStringsArray, ...args: unknown[] ): Promise>; - async queryObject< - T = Record, - >( + async queryObject>( query_template_or_config: | string | QueryObjectOptions @@ -536,87 +601,93 @@ export class Transaction { } try { - return await this.#executeQuery(query) as QueryObjectResult; + return (await this.#executeQuery(query)) as QueryObjectResult; } catch (e) { if (e instanceof PostgresError) { await this.commit(); throw new TransactionError(this.name, e); - } else { - throw e; } + throw e; } } /** * Rollbacks are a mechanism to undo transaction operations without compromising the data that was modified during - * the transaction + * the transaction. * - * A rollback can be executed the following way - * ```ts - * import { Client } from "../client.ts"; + * Calling a rollback without arguments will terminate the current transaction and undo all changes. * + * ```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(); * ``` * - * Calling a rollback without arguments will terminate the current transaction and undo all changes, - * but it can be used in conjuction with the savepoint feature to rollback specific changes like the following + * https://www.postgresql.org/docs/14/sql-rollback.html + */ + async rollback(): Promise; + /** + * Savepoints can be used to rollback specific changes part of a transaction. * * ```ts - * import { Client } from "../client.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.rollback(savepoint); // "before_disaster" would work as well - * // Everything that happened between the savepoint and the rollback gets undone + * 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); + * await transaction.rollback('before_disaster') + * await transaction.rollback({ savepoint: 'before_disaster'}) + * * await transaction.commit(); // Commits all other changes + * await client.end(); * ``` - * - * The rollback method allows you to specify a "chain" option, that allows you to not only undo the current transaction - * but to restart it with the same parameters in a single statement + */ + async rollback( + savepoint?: string | Savepoint | { savepoint?: string | Savepoint }, + ): Promise; + /** + * 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 "../client.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(); * ``` - * - * However, the "chain" option can't be used alongside a savepoint, even though they are similar - * - * A savepoint is meant to reset progress up to a certain point, while a chained rollback is meant to reset all progress - * and start from scratch - * - * ```ts - * import { Client } from "../client.ts"; - * - * const client = new Client(); - * const transaction = client.createTransaction("transaction"); - * - * // @ts-expect-error - * await transaction.rollback({ chain: true, savepoint: "my_savepoint" }); // Error, can't both return to savepoint and reset transaction - * ``` - * https://www.postgresql.org/docs/14/sql-rollback.html */ - async rollback(savepoint?: string | Savepoint): Promise; - async rollback(options?: { savepoint?: string | Savepoint }): Promise; async rollback(options?: { chain?: boolean }): Promise; async rollback( - savepoint_or_options?: string | Savepoint | { - savepoint?: string | Savepoint; - } | { chain?: boolean }, + /** + * The "chain" and "savepoint" options can't be used alongside each other, even though they are similar. A savepoint is meant to reset progress up to a certain point, while a chained rollback is meant to reset all progress + * and start from scratch + */ + savepoint_or_options?: + | string + | Savepoint + | { + savepoint?: string | Savepoint; + } + | { chain?: boolean }, ): Promise { this.#assertTransactionOpen(); @@ -627,8 +698,9 @@ export class Transaction { ) { savepoint_option = savepoint_or_options; } else { - savepoint_option = - (savepoint_or_options as { savepoint?: string | Savepoint })?.savepoint; + savepoint_option = ( + savepoint_or_options as { savepoint?: string | Savepoint } + )?.savepoint; } let savepoint_name: string | undefined; @@ -652,8 +724,8 @@ export class Transaction { // If a savepoint is provided, rollback to that savepoint, continue the transaction if (typeof savepoint_option !== "undefined") { - const ts_savepoint = this.#savepoints.find(({ name }) => - name === savepoint_name + const ts_savepoint = this.#savepoints.find( + ({ name }) => name === savepoint_name, ); if (!ts_savepoint) { throw new Error( @@ -678,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(); @@ -702,45 +773,51 @@ export class Transaction { * * A savepoint can be easily created like this * ```ts - * import { Client } from "../client.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 "../client.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 "../client.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 */ @@ -772,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( @@ -793,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 4cd45602..38cc8c41 100644 --- a/tests/README.md +++ b/tests/README.md @@ -1,16 +1,23 @@ # 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 From within the project directory, run: -``` +```sh +# run on host deno test --allow-read --allow-net --allow-env + +# run in docker container +docker compose build --no-cache +docker compose run tests ``` ## Docker Configuration @@ -18,7 +25,7 @@ deno test --allow-read --allow-net --allow-env 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 fbd2b45f..0fb0507a 100644 --- a/tests/config.ts +++ b/tests/config.ts @@ -1,4 +1,7 @@ -import { ClientConfiguration } from "../connection/connection_params.ts"; +import type { + ClientConfiguration, + ClientOptions, +} from "../connection/connection_params.ts"; import config_file1 from "./config.json" with { type: "json" }; type TcpConfiguration = Omit & { @@ -12,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", ); @@ -67,17 +73,20 @@ export const getClearSocketConfiguration = (): SocketConfiguration => { }; /** MD5 authenticated user with privileged access to the database */ -export const getMainConfiguration = (): TcpConfiguration => { +export const getMainConfiguration = ( + _config?: ClientOptions, +): TcpConfiguration => { return { applicationName: config.postgres_md5.applicationName, database: config.postgres_md5.database, hostname: config.postgres_md5.hostname, - host_type: "tcp", - options: {}, password: config.postgres_md5.password, + user: config.postgres_md5.users.main, + ..._config, + options: {}, port: config.postgres_md5.port, tls: enabled_tls, - user: config.postgres_md5.users.main, + host_type: "tcp", }; }; 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 11fe426a..50cc7dd9 100644 --- a/tests/connection_test.ts +++ b/tests/connection_test.ts @@ -1,10 +1,5 @@ -import { - assertEquals, - assertRejects, - deferred, - joinPath, - streams, -} from "./test_deps.ts"; +import { assertEquals, assertRejects } from "jsr:@std/assert@1.0.10"; +import { join as joinPath } from "@std/path"; import { getClearConfiguration, getClearSocketConfiguration, @@ -26,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([ - streams.copy(conn, outbound), - streams.copy(outbound, conn), + conn.readable.pipeTo(outbound.writable), + outbound.readable.pipeTo(conn.writable), ]).catch(() => {}); - - if (!aborted) { - conn.close(); - outbound.close(); - } } })(); @@ -374,15 +363,15 @@ Deno.test("Closes connection on bad TLS availability verification", async functi ); // Await for server initialization - const initialized = deferred(); + const initialized = Promise.withResolvers(); server.onmessage = ({ data }) => { if (data !== "initialized") { initialized.reject(`Unexpected message "${data}" received from worker`); } - initialized.resolve(); + initialized.resolve(null); }; server.postMessage("initialize"); - await initialized; + await initialized.promise; const client = new Client({ database: "none", @@ -400,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; @@ -413,17 +402,17 @@ Deno.test("Closes connection on bad TLS availability verification", async functi await client.end(); } - const closed = deferred(); + const closed = Promise.withResolvers(); server.onmessage = ({ data }) => { if (data !== "closed") { closed.reject( `Unexpected message "${data}" received from worker`, ); } - closed.resolve(); + closed.resolve(null); }; server.postMessage("close"); - await closed; + await closed.promise; server.terminate(); assertEquals(bad_tls_availability_message, true); @@ -438,15 +427,15 @@ async function mockReconnection(attempts: number) { ); // Await for server initialization - const initialized = deferred(); + const initialized = Promise.withResolvers(); server.onmessage = ({ data }) => { if (data !== "initialized") { initialized.reject(`Unexpected message "${data}" received from worker`); } - initialized.resolve(); + initialized.resolve(null); }; server.postMessage("initialize"); - await initialized; + await initialized.promise; const client = new Client({ connection: { @@ -483,17 +472,17 @@ async function mockReconnection(attempts: number) { await client.end(); } - const closed = deferred(); + const closed = Promise.withResolvers(); server.onmessage = ({ data }) => { if (data !== "closed") { closed.reject( `Unexpected message "${data}" received from worker`, ); } - closed.resolve(); + closed.resolve(null); }; server.postMessage("close"); - await closed; + await closed.promise; server.terminate(); // If reconnections are set to zero, it will attempt to connect at least once, but won't @@ -587,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 d2741f3c..1dc1c463 100644 --- a/tests/data_types_test.ts +++ b/tests/data_types_test.ts @@ -1,10 +1,11 @@ -import { assertEquals, base64, date } 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 { Box, Circle, - Float4, + // Float4, Float8, Line, LineSegment, @@ -34,7 +35,7 @@ function generateRandomPoint(max_value = 100): Point { const CHARS = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"; function randomBase64(): string { - return base64.encode( + 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.decode(base64_string)); + assertEquals(result.rows[0][0], decodeBase64(base64_string)); }), ); @@ -691,7 +692,7 @@ Deno.test( assertEquals( result.rows[0][0], - strings.map(base64.decode), + strings.map(decodeBase64), ); }), ); @@ -856,22 +857,22 @@ Deno.test( Deno.test( "float4", testClient(async (client) => { - const result = await client.queryArray<[Float4, Float4]>( + const result = await client.queryArray<[number, number]>( "SELECT '1'::FLOAT4, '17.89'::FLOAT4", ); - assertEquals(result.rows[0], ["1", "17.89"]); + assertEquals(result.rows[0], [1, 17.89]); }), ); Deno.test( "float4 array", testClient(async (client) => { - const result = await client.queryArray<[[Float4, Float4]]>( + const result = await client.queryArray<[[number, number]]>( "SELECT ARRAY['12.25'::FLOAT4, '4789']", ); - assertEquals(result.rows[0][0], ["12.25", "4789"]); + assertEquals(result.rows[0][0], [12.25, 4789]); }), ); @@ -931,7 +932,7 @@ Deno.test( ); assertEquals(result.rows[0], [ - date.parse(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", date.format(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) => date.parse(d, "yyyy-MM-dd")), + dates.map((d) => new Date(d)), ); }), ); diff --git a/tests/decode_test.ts b/tests/decode_test.ts new file mode 100644 index 00000000..b2f0657f --- /dev/null +++ b/tests/decode_test.ts @@ -0,0 +1,327 @@ +import { Column, decode } from "../query/decode.ts"; +import { + decodeBigint, + decodeBigintArray, + decodeBoolean, + decodeBooleanArray, + decodeBox, + decodeCircle, + decodeDate, + decodeDatetime, + decodeFloat, + decodeInt, + decodeJson, + decodeLine, + decodeLineSegment, + decodePath, + decodePoint, + decodeTid, +} from "../query/decoders.ts"; +import { assertEquals, assertThrows } from "jsr:@std/assert@1.0.10"; +import { Oid } from "../query/oid.ts"; + +Deno.test("decodeBigint", function () { + assertEquals(decodeBigint("18014398509481984"), 18014398509481984n); +}); + +Deno.test("decodeBigintArray", function () { + assertEquals( + decodeBigintArray( + "{17365398509481972,9007199254740992,-10414398509481984}", + ), + [17365398509481972n, 9007199254740992n, -10414398509481984n], + ); +}); + +Deno.test("decodeBoolean", function () { + assertEquals(decodeBoolean("True"), true); + assertEquals(decodeBoolean("yEs"), true); + assertEquals(decodeBoolean("T"), true); + assertEquals(decodeBoolean("t"), true); + assertEquals(decodeBoolean("YeS"), true); + assertEquals(decodeBoolean("On"), true); + assertEquals(decodeBoolean("1"), true); + assertEquals(decodeBoolean("no"), false); + assertEquals(decodeBoolean("off"), false); + assertEquals(decodeBoolean("0"), false); + assertEquals(decodeBoolean("F"), false); + assertEquals(decodeBoolean("false"), false); + assertEquals(decodeBoolean("n"), false); + assertEquals(decodeBoolean(""), false); +}); + +Deno.test("decodeBooleanArray", function () { + assertEquals(decodeBooleanArray("{True,0,T}"), [true, false, true]); + assertEquals(decodeBooleanArray("{no,Y,1}"), [false, true, true]); +}); + +Deno.test("decodeBox", function () { + assertEquals(decodeBox("(12.4,2),(33,4.33)"), { + a: { x: "12.4", y: "2" }, + b: { x: "33", y: "4.33" }, + }); + let testValue = "(12.4,2)"; + assertThrows( + () => decodeBox(testValue), + Error, + `Invalid Box: "${testValue}". Box must have only 2 point, 1 given.`, + ); + testValue = "(12.4,2),(123,123,123),(9303,33)"; + assertThrows( + () => decodeBox(testValue), + Error, + `Invalid Box: "${testValue}". Box must have only 2 point, 3 given.`, + ); + testValue = "(0,0),(123,123,123)"; + assertThrows( + () => decodeBox(testValue), + Error, + `Invalid Box: "${testValue}" : Invalid Point: "(123,123,123)". Points must have only 2 coordinates, 3 given.`, + ); + testValue = "(0,0),(100,r100)"; + assertThrows( + () => decodeBox(testValue), + Error, + `Invalid Box: "${testValue}" : Invalid Point: "(100,r100)". Coordinate "r100" must be a valid number.`, + ); +}); + +Deno.test("decodeCircle", function () { + assertEquals(decodeCircle("<(12.4,2),3.5>"), { + point: { x: "12.4", y: "2" }, + radius: "3.5", + }); + let testValue = "<(c21 23,2),3.5>"; + assertThrows( + () => decodeCircle(testValue), + Error, + `Invalid Circle: "${testValue}" : Invalid Point: "(c21 23,2)". Coordinate "c21 23" must be a valid number.`, + ); + testValue = "<(33,2),mn23 3.5>"; + assertThrows( + () => decodeCircle(testValue), + Error, + `Invalid Circle: "${testValue}". Circle radius "mn23 3.5" must be a valid number.`, + ); +}); + +Deno.test("decodeDate", function () { + assertEquals(decodeDate("2021-08-01"), new Date("2021-08-01 00:00:00-00")); +}); + +Deno.test("decodeDatetime", function () { + assertEquals( + decodeDatetime("2021-08-01"), + new Date("2021-08-01 00:00:00-00"), + ); + assertEquals( + decodeDatetime("1997-12-17 07:37:16-08"), + new Date("1997-12-17 07:37:16-08"), + ); +}); + +Deno.test("decodeFloat", function () { + assertEquals(decodeFloat("3.14"), 3.14); + assertEquals(decodeFloat("q743 44 23i4"), NaN); +}); + +Deno.test("decodeInt", function () { + assertEquals(decodeInt("42"), 42); + assertEquals(decodeInt("q743 44 23i4"), NaN); +}); + +Deno.test("decodeJson", function () { + assertEquals( + decodeJson( + '{"key_1": "MY VALUE", "key_2": null, "key_3": 10, "key_4": {"subkey_1": true, "subkey_2": ["1",2]}}', + ), + { + key_1: "MY VALUE", + key_2: null, + key_3: 10, + key_4: { subkey_1: true, subkey_2: ["1", 2] }, + }, + ); + assertThrows(() => decodeJson("{ 'eqw' ; ddd}")); +}); + +Deno.test("decodeLine", function () { + assertEquals(decodeLine("{100,50,0}"), { a: "100", b: "50", c: "0" }); + let testValue = "{100,50,0,100}"; + assertThrows( + () => decodeLine("{100,50,0,100}"), + Error, + `Invalid Line: "${testValue}". Line in linear equation format must have 3 constants, 4 given.`, + ); + testValue = "{100,d3km,0}"; + assertThrows( + () => decodeLine(testValue), + Error, + `Invalid Line: "${testValue}". Line constant "d3km" must be a valid number.`, + ); +}); + +Deno.test("decodeLineSegment", function () { + assertEquals(decodeLineSegment("((100,50),(350,350))"), { + a: { x: "100", y: "50" }, + b: { x: "350", y: "350" }, + }); + let testValue = "((100,50),(r344,350))"; + assertThrows( + () => decodeLineSegment(testValue), + Error, + `Invalid Line Segment: "${testValue}" : Invalid Point: "(r344,350)". Coordinate "r344" must be a valid number.`, + ); + testValue = "((100),(r344,350))"; + assertThrows( + () => decodeLineSegment(testValue), + Error, + `Invalid Line Segment: "${testValue}" : Invalid Point: "(100)". Points must have only 2 coordinates, 1 given.`, + ); + testValue = "((100,50))"; + assertThrows( + () => decodeLineSegment(testValue), + Error, + `Invalid Line Segment: "${testValue}". Line segments must have only 2 point, 1 given.`, + ); + testValue = "((100,50),(350,350),(100,100))"; + assertThrows( + () => decodeLineSegment(testValue), + Error, + `Invalid Line Segment: "${testValue}". Line segments must have only 2 point, 3 given.`, + ); +}); + +Deno.test("decodePath", function () { + assertEquals(decodePath("[(100,50),(350,350)]"), [ + { x: "100", y: "50" }, + { x: "350", y: "350" }, + ]); + assertEquals(decodePath("[(1,10),(2,20),(3,30)]"), [ + { x: "1", y: "10" }, + { x: "2", y: "20" }, + { x: "3", y: "30" }, + ]); + let testValue = "((100,50),(350,kjf334))"; + assertThrows( + () => decodePath(testValue), + Error, + `Invalid Path: "${testValue}" : Invalid Point: "(350,kjf334)". Coordinate "kjf334" must be a valid number.`, + ); + testValue = "((100,50,9949))"; + assertThrows( + () => decodePath(testValue), + Error, + `Invalid Path: "${testValue}" : Invalid Point: "(100,50,9949)". Points must have only 2 coordinates, 3 given.`, + ); +}); + +Deno.test("decodePoint", function () { + assertEquals(decodePoint("(10.555,50.8)"), { x: "10.555", y: "50.8" }); + let testValue = "(1000)"; + assertThrows( + () => decodePoint(testValue), + Error, + `Invalid Point: "${testValue}". Points must have only 2 coordinates, 1 given.`, + ); + testValue = "(100.100,50,350)"; + assertThrows( + () => decodePoint(testValue), + Error, + `Invalid Point: "${testValue}". Points must have only 2 coordinates, 3 given.`, + ); + testValue = "(1,r344)"; + assertThrows( + () => decodePoint(testValue), + Error, + `Invalid Point: "${testValue}". Coordinate "r344" must be a valid number.`, + ); + testValue = "(cd 213ee,100)"; + assertThrows( + () => decodePoint(testValue), + Error, + `Invalid Point: "${testValue}". Coordinate "cd 213ee" must be a valid number.`, + ); +}); + +Deno.test("decodeTid", function () { + assertEquals(decodeTid("(19714398509481984,29383838509481984)"), [ + 19714398509481984n, + 29383838509481984n, + ]); +}); + +Deno.test("decode strategy", function () { + const testValues = [ + { + value: "40", + column: new Column("test", 0, 0, Oid.int4, 0, 0, 0), + parsed: 40, + }, + { + value: "my_value", + column: new Column("test", 0, 0, Oid.text, 0, 0, 0), + parsed: "my_value", + }, + { + value: "[(100,50),(350,350)]", + column: new Column("test", 0, 0, Oid.path, 0, 0, 0), + parsed: [ + { x: "100", y: "50" }, + { x: "350", y: "350" }, + ], + }, + { + value: '{"value_1","value_2","value_3"}', + column: new Column("test", 0, 0, Oid.text_array, 0, 0, 0), + parsed: ["value_1", "value_2", "value_3"], + }, + { + value: "1997-12-17 07:37:16-08", + column: new Column("test", 0, 0, Oid.timestamp, 0, 0, 0), + parsed: new Date("1997-12-17 07:37:16-08"), + }, + { + value: "Yes", + column: new Column("test", 0, 0, Oid.bool, 0, 0, 0), + parsed: true, + }, + { + value: "<(12.4,2),3.5>", + column: new Column("test", 0, 0, Oid.circle, 0, 0, 0), + parsed: { point: { x: "12.4", y: "2" }, radius: "3.5" }, + }, + { + value: '{"test":1,"val":"foo","example":[1,2,false]}', + column: new Column("test", 0, 0, Oid.jsonb, 0, 0, 0), + parsed: { test: 1, val: "foo", example: [1, 2, false] }, + }, + { + value: "18014398509481984", + column: new Column("test", 0, 0, Oid.int8, 0, 0, 0), + parsed: 18014398509481984n, + }, + { + value: "{3.14,1.11,0.43,200}", + column: new Column("test", 0, 0, Oid.float4_array, 0, 0, 0), + parsed: [3.14, 1.11, 0.43, 200], + }, + ]; + + for (const testValue of testValues) { + const encodedValue = new TextEncoder().encode(testValue.value); + + // check default behavior + assertEquals(decode(encodedValue, testValue.column), testValue.parsed); + // check 'auto' behavior + assertEquals( + decode(encodedValue, testValue.column, { decodeStrategy: "auto" }), + testValue.parsed, + ); + // check 'string' behavior + assertEquals( + decode(encodedValue, testValue.column, { decodeStrategy: "string" }), + testValue.value, + ); + } +}); 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 46ec5a05..26966de4 100644 --- a/tests/query_client_test.ts +++ b/tests/query_client_test.ts @@ -8,17 +8,22 @@ import { import { assert, assertEquals, + assertInstanceOf, assertObjectMatch, assertRejects, -} from "./test_deps.ts"; + assertThrows, +} from "jsr:@std/assert@1.0.10"; import { getMainConfiguration } from "./config.ts"; -import { PoolClient, QueryClient } from "../client.ts"; +import type { PoolClient, QueryClient } from "../client.ts"; +import type { ClientOptions } from "../connection/connection_params.ts"; +import { Oid } from "../query/oid.ts"; function withClient( t: (client: QueryClient) => void | Promise, + config?: ClientOptions, ) { async function clientWrapper() { - const client = new Client(getMainConfiguration()); + const client = new Client(getMainConfiguration(config)); try { await client.connect(); await t(client); @@ -28,7 +33,7 @@ function withClient( } async function poolWrapper() { - const pool = new Pool(getMainConfiguration(), 1); + const pool = new Pool(getMainConfiguration(config), 1); let client; try { client = await pool.connect(); @@ -111,15 +116,291 @@ Deno.test( }), ); +Deno.test( + "Decode strategy - auto", + withClient( + async (client) => { + const result = await client.queryObject( + `SELECT + 'Y'::BOOLEAN AS _bool, + 3.14::REAL AS _float, + ARRAY[1, 2, 3] AS _int_array, + '{"test": "foo", "arr": [1,2,3]}'::JSONB AS _jsonb, + 'DATA' AS _text + ;`, + ); + + assertEquals(result.rows, [ + { + _bool: true, + _float: 3.14, + _int_array: [1, 2, 3], + _jsonb: { test: "foo", arr: [1, 2, 3] }, + _text: "DATA", + }, + ]); + }, + { controls: { decodeStrategy: "auto" } }, + ), +); + +Deno.test( + "Decode strategy - string", + withClient( + async (client) => { + const result = await client.queryObject( + `SELECT + 'Y'::BOOLEAN AS _bool, + 3.14::REAL AS _float, + ARRAY[1, 2, 3] AS _int_array, + '{"test": "foo", "arr": [1,2,3]}'::JSONB AS _jsonb, + 'DATA' AS _text + ;`, + ); + + assertEquals(result.rows, [ + { + _bool: "t", + _float: "3.14", + _int_array: "{1,2,3}", + _jsonb: '{"arr": [1, 2, 3], "test": "foo"}', + _text: "DATA", + }, + ]); + }, + { controls: { decodeStrategy: "string" } }, + ), +); + +Deno.test( + "Custom decoders", + withClient( + async (client) => { + const result = await client.queryObject( + `SELECT + 0::BOOLEAN AS _bool, + (DATE '2024-01-01' + INTERVAL '2 months')::DATE AS _date, + 7.90::REAL AS _float, + 100 AS _int, + '{"foo": "a", "bar": [1,2,3], "baz": null}'::JSONB AS _jsonb, + 'MY_VALUE' AS _text, + DATE '2024-10-01' + INTERVAL '2 years' - INTERVAL '2 months' AS _timestamp + ;`, + ); + + assertEquals(result.rows, [ + { + _bool: { boolean: false }, + _date: new Date("2024-03-03T00:00:00.000Z"), + _float: 785, + _int: 200, + _jsonb: { id: "999", foo: "A", bar: [2, 4, 6], baz: "initial" }, + _text: ["E", "U", "L", "A", "V", "_", "Y", "M"], + _timestamp: { year: 2126, month: "---08" }, + }, + ]); + }, + { + controls: { + decoders: { + // convert to object + [Oid.bool]: (value: string) => ({ boolean: value === "t" }), + // 1082 = date : convert to date and add 2 days + "1082": (value: string) => { + const d = new Date(value); + return new Date(d.setDate(d.getDate() + 2)); + }, + // multiply by 100 - 5 = 785 + float4: (value: string) => parseFloat(value) * 100 - 5, + // convert to int and add 100 = 200 + [Oid.int4]: (value: string) => parseInt(value, 10) + 100, + // parse with multiple conditions + jsonb: (value: string) => { + const obj = JSON.parse(value); + obj.foo = obj.foo.toUpperCase(); + obj.id = "999"; + obj.bar = obj.bar.map((v: number) => v * 2); + if (obj.baz === null) obj.baz = "initial"; + return obj; + }, + // split string and reverse + [Oid.text]: (value: string) => value.split("").reverse(), + // 1114 = timestamp : format timestamp into custom object + 1114: (value: string) => { + const d = new Date(value); + return { + year: d.getFullYear() + 100, + month: `---${d.getMonth() + 1 < 10 ? "0" : ""}${ + d.getMonth() + 1 + }`, + }; + }, + }, + }, + }, + ), +); + +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( + async (client) => { + const result = await client.queryObject( + `SELECT + 0::BOOLEAN AS _bool, + 1 AS _int, + 1::REAL AS _float, + 'TEST' AS _text + ;`, + ); + + assertEquals(result.rows, [ + { + _bool: "success", + _float: "success", + _int: "success", + _text: "success", + }, + ]); + }, + { + controls: { + // numeric oid type values take precedence over name + decoders: { + // bool + bool: () => "fail", + [16]: () => "success", + //int + int4: () => "fail", + [Oid.int4]: () => "success", + // float4 + float4: () => "fail", + "700": () => "success", + // text + text: () => "fail", + 25: () => "success", + }, + }, + }, + ), +); + +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) => { { const value = "1"; - const result = await client.queryArray( - "SELECT $1", - [value], - ); + const result = await client.queryArray("SELECT $1", [value]); assertEquals(result.rows, [[value]]); } @@ -134,10 +415,7 @@ Deno.test( { const value = "3"; - const result = await client.queryObject( - "SELECT $1 AS ID", - [value], - ); + const result = await client.queryObject("SELECT $1 AS ID", [value]); assertEquals(result.rows, [{ id: value }]); } @@ -157,10 +435,7 @@ Deno.test( withClient(async (client) => { { const value = "1"; - const result = await client.queryArray( - "SELECT $id", - { id: value }, - ); + const result = await client.queryArray("SELECT $id", { id: value }); assertEquals(result.rows, [[value]]); } @@ -175,10 +450,9 @@ Deno.test( { const value = "3"; - const result = await client.queryObject( - "SELECT $id as ID", - { id: value }, - ); + const result = await client.queryObject("SELECT $id as ID", { + id: value, + }); assertEquals(result.rows, [{ id: value }]); } @@ -217,10 +491,9 @@ Deno.test( await client.queryArray`CREATE TEMP TABLE PREPARED_STATEMENT_ERROR (X INT)`; await assertRejects(() => - client.queryArray( - "INSERT INTO PREPARED_STATEMENT_ERROR VALUES ($1)", - ["TEXT"], - ) + client.queryArray("INSERT INTO PREPARED_STATEMENT_ERROR VALUES ($1)", [ + "TEXT", + ]) ); const { rows } = await client.queryObject<{ result: number }>({ @@ -236,10 +509,7 @@ Deno.test( "Array query can handle multiple query failures at once", withClient(async (client) => { await assertRejects( - () => - client.queryArray( - "SELECT 1; SELECT '2'::INT; SELECT 'A'::INT", - ), + () => client.queryArray("SELECT 1; SELECT '2'::INT; SELECT 'A'::INT"), PostgresError, "invalid input syntax for type integer", ); @@ -256,9 +526,7 @@ Deno.test( Deno.test( "Array query handles error during data processing", withClient(async (client) => { - await assertRejects( - () => client.queryObject`SELECT 'A' AS X, 'B' AS X`, - ); + await assertRejects(() => client.queryObject`SELECT 'A' AS X, 'B' AS X`); const value = "193"; const { rows: result_2 } = await client.queryObject`SELECT ${value} AS B`; @@ -291,11 +559,13 @@ Deno.test( withClient(async (client) => { await client.queryArray`CREATE TEMP TABLE PREPARED_STATEMENT_ERROR (X INT)`; - await assertRejects(() => - client.queryArray( - "INSERT INTO PREPARED_STATEMENT_ERROR VALUES ($1)", - ["TEXT"], - ), PostgresError); + await assertRejects( + () => + client.queryArray("INSERT INTO PREPARED_STATEMENT_ERROR VALUES ($1)", [ + "TEXT", + ]), + PostgresError, + ); const result = "handled"; @@ -312,9 +582,7 @@ Deno.test( Deno.test( "Prepared query handles error during data processing", withClient(async (client) => { - await assertRejects( - () => client.queryObject`SELECT ${1} AS A, ${2} AS A`, - ); + await assertRejects(() => client.queryObject`SELECT ${1} AS A, ${2} AS A`); const value = "z"; const { rows: result_2 } = await client.queryObject`SELECT ${value} AS B`; @@ -328,10 +596,10 @@ Deno.test( const item_1 = "Test;Azer"; const item_2 = "123;456"; - const { rows: result_1 } = await client.queryArray( - `SELECT ARRAY[$1, $2]`, - [item_1, item_2], - ); + const { rows: result_1 } = await client.queryArray(`SELECT ARRAY[$1, $2]`, [ + item_1, + item_2, + ]); assertEquals(result_1[0], [[item_1, item_2]]); }), ); @@ -440,10 +708,7 @@ Deno.test( text: `SELECT 1`, fields: ["res"], }); - assertEquals( - result[0].res, - 1, - ); + assertEquals(result[0].res, 1); assertEquals(client.connected, true); }), @@ -469,9 +734,7 @@ Deno.test( Deno.test( "Handling of query notices", withClient(async (client) => { - await client.queryArray( - "CREATE TEMP TABLE NOTICE_TEST (ABC INT);", - ); + await client.queryArray("CREATE TEMP TABLE NOTICE_TEST (ABC INT);"); const { warnings } = await client.queryArray( "CREATE TEMP TABLE IF NOT EXISTS NOTICE_TEST (ABC INT);", ); @@ -543,10 +806,9 @@ Deno.test( assertEquals(result_1[0][0], expectedBytes); - const { rows: result_2 } = await client.queryArray( - "SELECT $1::BYTEA", - [expectedBytes], - ); + const { rows: result_2 } = await client.queryArray("SELECT $1::BYTEA", [ + expectedBytes, + ]); assertEquals(result_2[0][0], expectedBytes); }), ); @@ -583,10 +845,9 @@ Deno.test( assertEquals(result.rowCount, 2); // parameterized delete - result = await client.queryArray( - "DELETE FROM METADATA WHERE VALUE = $1", - [300], - ); + result = await client.queryArray("DELETE FROM METADATA WHERE VALUE = $1", [ + 300, + ]); assertEquals(result.command, "DELETE"); assertEquals(result.rowCount, 1); @@ -625,7 +886,7 @@ Deno.test( `); assertEquals(result, [ - { "very_very_very_very_very_very_very_very_very_very_very_long_nam": 1 }, + { very_very_very_very_very_very_very_very_very_very_very_long_nam: 1 }, ]); assert(warnings[0].message.includes("will be truncated")); @@ -646,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", @@ -656,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", }); @@ -665,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", @@ -675,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", }); @@ -696,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", }); @@ -738,7 +999,7 @@ Deno.test( await assertRejects( () => client.queryObject({ - camelcase: true, + camelCase: true, text: `SELECT 1 AS "fieldX", 2 AS field_x`, }), Error, @@ -755,10 +1016,7 @@ Deno.test( fields: ["a"], }); - assertEquals( - result_1[0].a, - 1, - ); + assertEquals(result_1[0].a, 1); await assertRejects( async () => { @@ -847,14 +1105,27 @@ Deno.test( withClient(async (client) => { const value = { x: "A", y: "B" }; - const { rows } = await client.queryObject< - { x: string; y: string } - >`SELECT ${value.x} AS x, ${value.y} AS y`; + const { rows } = await client.queryObject<{ + x: string; + y: string; + }>`SELECT ${value.x} AS x, ${value.y} AS y`; assertEquals(rows[0], value); }), ); +Deno.test( + "Transaction parameter validation", + withClient((client) => { + assertThrows( + // deno-lint-ignore ban-ts-comment + // @ts-expect-error + () => client.createTransaction(), + "Transaction name must be a non-empty string", + ); + }), +); + Deno.test( "Transaction", withClient(async (client) => { @@ -870,18 +1141,18 @@ Deno.test( await transaction.queryArray`CREATE TEMP TABLE TEST (X INTEGER)`; const savepoint = await transaction.savepoint("table_creation"); await transaction.queryArray`INSERT INTO TEST (X) VALUES (1)`; - const query_1 = await transaction.queryObject< - { x: number } - >`SELECT X FROM TEST`; + const query_1 = await transaction.queryObject<{ + x: number; + }>`SELECT X FROM TEST`; assertEquals( query_1.rows[0].x, 1, "Operation was not executed inside transaction", ); await transaction.rollback(savepoint); - const query_2 = await transaction.queryObject< - { x: number } - >`SELECT X FROM TEST`; + const query_2 = await transaction.queryObject<{ + x: number; + }>`SELECT X FROM TEST`; assertEquals( query_2.rowCount, 0, @@ -940,21 +1211,21 @@ Deno.test( await transaction_rr.begin(); // This locks the current value of the test table - await transaction_rr.queryObject< - { x: number } - >`SELECT X FROM FOR_TRANSACTION_TEST`; + await transaction_rr.queryObject<{ + x: number; + }>`SELECT X FROM FOR_TRANSACTION_TEST`; // Modify data outside the transaction await client_2.queryArray`UPDATE FOR_TRANSACTION_TEST SET X = 2`; - const { rows: query_1 } = await client_2.queryObject< - { x: number } - >`SELECT X FROM FOR_TRANSACTION_TEST`; + const { rows: query_1 } = await client_2.queryObject<{ + x: number; + }>`SELECT X FROM FOR_TRANSACTION_TEST`; assertEquals(query_1, [{ x: 2 }]); - const { rows: query_2 } = await transaction_rr.queryObject< - { x: number } - >`SELECT X FROM FOR_TRANSACTION_TEST`; + const { rows: query_2 } = await transaction_rr.queryObject<{ + x: number; + }>`SELECT X FROM FOR_TRANSACTION_TEST`; assertEquals( query_2, [{ x: 1 }], @@ -963,9 +1234,9 @@ Deno.test( await transaction_rr.commit(); - const { rows: query_3 } = await client_1.queryObject< - { x: number } - >`SELECT X FROM FOR_TRANSACTION_TEST`; + const { rows: query_3 } = await client_1.queryObject<{ + x: number; + }>`SELECT X FROM FOR_TRANSACTION_TEST`; assertEquals( query_3, [{ x: 2 }], @@ -994,9 +1265,9 @@ Deno.test( await transaction_rr.begin(); // This locks the current value of the test table - await transaction_rr.queryObject< - { x: number } - >`SELECT X FROM FOR_TRANSACTION_TEST`; + await transaction_rr.queryObject<{ + x: number; + }>`SELECT X FROM FOR_TRANSACTION_TEST`; // Modify data outside the transaction await client_2.queryArray`UPDATE FOR_TRANSACTION_TEST SET X = 2`; @@ -1008,9 +1279,9 @@ Deno.test( "A serializable transaction should throw if the data read in the transaction has been modified externally", ); - const { rows: query_3 } = await client_1.queryObject< - { x: number } - >`SELECT X FROM FOR_TRANSACTION_TEST`; + const { rows: query_3 } = await client_1.queryObject<{ + x: number; + }>`SELECT X FROM FOR_TRANSACTION_TEST`; assertEquals( query_3, [{ x: 2 }], @@ -1051,23 +1322,22 @@ Deno.test( await client_1.queryArray`DROP TABLE IF EXISTS FOR_TRANSACTION_TEST`; await client_1.queryArray`CREATE TABLE FOR_TRANSACTION_TEST (X INTEGER)`; await client_1.queryArray`INSERT INTO FOR_TRANSACTION_TEST (X) VALUES (1)`; - const transaction_1 = client_1.createTransaction( - "transactionSnapshot1", - { isolation_level: "repeatable_read" }, - ); + const transaction_1 = client_1.createTransaction("transactionSnapshot1", { + isolation_level: "repeatable_read", + }); await transaction_1.begin(); // This locks the current value of the test table - await transaction_1.queryObject< - { x: number } - >`SELECT X FROM FOR_TRANSACTION_TEST`; + await transaction_1.queryObject<{ + x: number; + }>`SELECT X FROM FOR_TRANSACTION_TEST`; // Modify data outside the transaction await client_2.queryArray`UPDATE FOR_TRANSACTION_TEST SET X = 2`; - const { rows: query_1 } = await transaction_1.queryObject< - { x: number } - >`SELECT X FROM FOR_TRANSACTION_TEST`; + const { rows: query_1 } = await transaction_1.queryObject<{ + x: number; + }>`SELECT X FROM FOR_TRANSACTION_TEST`; assertEquals( query_1, [{ x: 1 }], @@ -1076,15 +1346,15 @@ Deno.test( const snapshot = await transaction_1.getSnapshot(); - const transaction_2 = client_2.createTransaction( - "transactionSnapshot2", - { isolation_level: "repeatable_read", snapshot }, - ); + const transaction_2 = client_2.createTransaction("transactionSnapshot2", { + isolation_level: "repeatable_read", + snapshot, + }); await transaction_2.begin(); - const { rows: query_2 } = await transaction_2.queryObject< - { x: number } - >`SELECT X FROM FOR_TRANSACTION_TEST`; + const { rows: query_2 } = await transaction_2.queryObject<{ + x: number; + }>`SELECT X FROM FOR_TRANSACTION_TEST`; assertEquals( query_2, [{ x: 1 }], @@ -1157,9 +1427,9 @@ Deno.test( await transaction.begin(); await transaction.queryArray`INSERT INTO MY_TEST (X) VALUES (1)`; - const { rows: query_1 } = await transaction.queryObject< - { x: number } - >`SELECT X FROM MY_TEST`; + const { rows: query_1 } = await transaction.queryObject<{ + x: number; + }>`SELECT X FROM MY_TEST`; assertEquals(query_1, [{ x: 1 }]); await transaction.rollback({ chain: true }); @@ -1172,9 +1442,9 @@ Deno.test( await transaction.rollback(); - const { rowCount: query_2 } = await client.queryObject< - { x: number } - >`SELECT X FROM MY_TEST`; + const { rowCount: query_2 } = await client.queryObject<{ + x: number; + }>`SELECT X FROM MY_TEST`; assertEquals(query_2, 0); assertEquals( @@ -1237,31 +1507,31 @@ Deno.test( await transaction.begin(); await transaction.queryArray`CREATE TEMP TABLE X (Y INT)`; await transaction.queryArray`INSERT INTO X VALUES (1)`; - const { rows: query_1 } = await transaction.queryObject< - { y: number } - >`SELECT Y FROM X`; + const { rows: query_1 } = await transaction.queryObject<{ + y: number; + }>`SELECT Y FROM X`; assertEquals(query_1, [{ y: 1 }]); const savepoint = await transaction.savepoint(savepoint_name); await transaction.queryArray`DELETE FROM X`; - const { rowCount: query_2 } = await transaction.queryObject< - { y: number } - >`SELECT Y FROM X`; + const { rowCount: query_2 } = await transaction.queryObject<{ + y: number; + }>`SELECT Y FROM X`; assertEquals(query_2, 0); await savepoint.update(); await transaction.queryArray`INSERT INTO X VALUES (2)`; - const { rows: query_3 } = await transaction.queryObject< - { y: number } - >`SELECT Y FROM X`; + const { rows: query_3 } = await transaction.queryObject<{ + y: number; + }>`SELECT Y FROM X`; assertEquals(query_3, [{ y: 2 }]); await transaction.rollback(savepoint); - const { rowCount: query_4 } = await transaction.queryObject< - { y: number } - >`SELECT Y FROM X`; + const { rowCount: query_4 } = await transaction.queryObject<{ + y: number; + }>`SELECT Y FROM X`; assertEquals(query_4, 0); assertEquals( @@ -1278,9 +1548,9 @@ Deno.test( // This checks that the savepoint can be called by name as well await transaction.rollback(savepoint_name); - const { rows: query_5 } = await transaction.queryObject< - { y: number } - >`SELECT Y FROM X`; + const { rows: query_5 } = await transaction.queryObject<{ + y: number; + }>`SELECT Y FROM X`; assertEquals(query_5, [{ y: 1 }]); await transaction.commit(); diff --git a/tests/test_deps.ts b/tests/test_deps.ts index b813d31f..cb56ee54 100644 --- a/tests/test_deps.ts +++ b/tests/test_deps.ts @@ -1,10 +1,9 @@ -export * from "../deps.ts"; export { assert, assertEquals, + assertInstanceOf, assertNotEquals, assertObjectMatch, assertRejects, assertThrows, -} from "https://deno.land/std@0.160.0/testing/asserts.ts"; -export * as streams from "https://deno.land/std@0.160.0/streams/conversion.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/utils/deferred.ts b/utils/deferred.ts index e6378c50..9d650d90 100644 --- a/utils/deferred.ts +++ b/utils/deferred.ts @@ -1,4 +1,4 @@ -import { type Deferred, deferred } from "../deps.ts"; +export type Deferred = ReturnType>; export class DeferredStack { #elements: Array; @@ -7,11 +7,7 @@ export class DeferredStack { #queue: Array>; #size: number; - constructor( - max?: number, - ls?: Iterable, - creator?: () => Promise, - ) { + constructor(max?: number, ls?: Iterable, creator?: () => Promise) { this.#elements = ls ? [...ls] : []; this.#creator = creator; this.#max_size = max || 10; @@ -26,13 +22,15 @@ 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(); } - const d = deferred(); + const d = Promise.withResolvers(); this.#queue.push(d); - return await d; + return await d.promise; } push(value: T): void { @@ -100,9 +98,7 @@ export class DeferredAccessStack { this.#elements.map((e) => this.#checkElementInitialization(e)), ); - return initialized - .filter((initialized) => initialized === true) - .length; + return initialized.filter((initialized) => initialized === true).length; } async pop(): Promise { @@ -112,12 +108,12 @@ export class DeferredAccessStack { } else { // If there are not elements left in the stack, it will await the call until // at least one is restored and then return it - const d = deferred(); + const d = Promise.withResolvers(); this.#queue.push(d); - element = await d; + element = await d.promise; } - if (!await this.#checkElementInitialization(element)) { + if (!(await this.#checkElementInitialization(element))) { await this.#initializeElement(element); } return element; diff --git a/utils/utils.ts b/utils/utils.ts index 3add6096..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; @@ -43,6 +43,20 @@ export interface Uri { user: string; } +type ConnectionInfo = { + driver?: string; + user?: string; + password?: string; + full_host?: string; + path?: string; + params?: string; +}; + +type ParsedHost = { + host?: string; + port?: string; +}; + /** * This function parses valid connection strings according to https://www.postgresql.org/docs/14/libpq-connect.html#LIBPQ-CONNSTRING * @@ -53,6 +67,7 @@ export function parseConnectionUri(uri: string): Uri { /(?\w+):\/{2}((?[^\/?#\s:]+?)?(:(?[^\/?#\s]+)?)?@)?(?[^\/?#\s]+)?(\/(?[^?#\s]*))?(\?(?[^#\s]+))?.*/, ); if (!parsed_uri) throw new Error("Could not parse the provided URL"); + let { driver = "", full_host = "", @@ -60,26 +75,17 @@ export function parseConnectionUri(uri: string): Uri { password = "", path = "", user = "", - }: { - driver?: string; - user?: string; - password?: string; - full_host?: string; - path?: string; - params?: string; - } = parsed_uri.groups ?? {}; + }: ConnectionInfo = parsed_uri.groups ?? {}; const parsed_host = full_host.match( /(?(\[.+\])|(.*?))(:(?[\w]*))?$/, ); if (!parsed_host) throw new Error(`Could not parse "${full_host}" host`); + let { host = "", port = "", - }: { - host?: string; - port?: string; - } = parsed_host.groups ?? {}; + }: ParsedHost = parsed_host.groups ?? {}; try { if (host) { @@ -87,9 +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`), ); } @@ -104,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`, ), ); }