├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ ├── ci.yml │ └── publish_jsr.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── client.ts ├── client └── error.ts ├── connection ├── auth.ts ├── connection.ts ├── connection_params.ts ├── message.ts ├── message_code.ts ├── packet.ts └── scram.ts ├── debug.ts ├── deno.json ├── docker-compose.yml ├── docker ├── certs │ ├── .gitignore │ ├── ca.crt │ └── domains.txt ├── generate_tls_keys.sh ├── postgres_clear │ ├── data │ │ ├── pg_hba.conf │ │ ├── postgresql.conf │ │ ├── server.crt │ │ └── server.key │ └── init │ │ ├── initialize_test_server.sh │ │ └── initialize_test_server.sql ├── postgres_md5 │ ├── data │ │ ├── pg_hba.conf │ │ ├── postgresql.conf │ │ ├── server.crt │ │ └── server.key │ └── init │ │ ├── initialize_test_server.sh │ │ └── initialize_test_server.sql └── postgres_scram │ ├── data │ ├── pg_hba.conf │ ├── postgresql.conf │ ├── server.crt │ └── server.key │ └── init │ ├── initialize_test_server.sh │ └── initialize_test_server.sql ├── docs ├── .nojekyll ├── CNAME ├── README.md ├── debug-output.png ├── deno-postgres.png └── index.html ├── mod.ts ├── pool.ts ├── query ├── array_parser.ts ├── decode.ts ├── decoders.ts ├── encode.ts ├── oid.ts ├── query.ts ├── transaction.ts └── types.ts ├── tests ├── README.md ├── auth_test.ts ├── config.json ├── config.ts ├── connection_params_test.ts ├── connection_test.ts ├── data_types_test.ts ├── decode_test.ts ├── encode_test.ts ├── helpers.ts ├── pool_test.ts ├── query_client_test.ts ├── test_deps.ts ├── utils_test.ts └── workers │ └── postgres_server.ts └── utils ├── deferred.ts └── utils.ts /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us fix and improve 4 | title: "" 5 | labels: "" 6 | assignees: "" 7 | --- 8 | 9 | **Describe the bug** 10 | 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | 15 | Steps to reproduce the behavior: 16 | 17 | 1. Go to '...' 18 | 2. Click on '....' 19 | 3. Scroll down to '....' 20 | 4. See error 21 | 22 | **Expected behavior** 23 | 24 | A clear and concise description of what you expected to happen. 25 | 26 | **Screenshots** 27 | 28 | If applicable, add screenshots to help explain your problem. 29 | 30 | **Additional context** 31 | 32 | If applicable, add any other context about the problem here. 33 | 34 | - deno-postgres version: 35 | - deno version: 36 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "" 5 | labels: "" 6 | assignees: "" 7 | --- 8 | 9 | **Is your feature request related to a problem? Please describe.** 10 | 11 | A clear and concise description of what the problem is. Ex. I'm always 12 | frustrated when [...] 13 | 14 | **Describe the solution you'd like** 15 | 16 | A clear and concise description of what you want to happen. 17 | 18 | **Describe alternatives you've considered** 19 | 20 | A clear and concise description of any alternative solutions or features you've 21 | considered. 22 | 23 | **Additional context** 24 | 25 | Add any other context or screenshots about the feature request here. 26 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Checks 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | code_quality: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Clone repo 13 | uses: actions/checkout@master 14 | 15 | - name: Setup Deno 16 | uses: denoland/setup-deno@v1 17 | with: 18 | deno-version: v2.x 19 | 20 | - name: Format 21 | run: deno fmt --check 22 | 23 | - name: Lint 24 | run: deno lint 25 | 26 | test_docs: 27 | runs-on: ubuntu-latest 28 | steps: 29 | - name: Clone repo 30 | uses: actions/checkout@master 31 | 32 | - name: Build tests container 33 | run: docker compose build tests 34 | 35 | - name: Run doc tests 36 | run: docker compose run doc_tests 37 | 38 | test: 39 | runs-on: ubuntu-latest 40 | steps: 41 | - name: Clone repo 42 | uses: actions/checkout@master 43 | 44 | - name: Build tests container 45 | run: docker compose build tests 46 | 47 | - name: Run tests 48 | run: docker compose run tests 49 | 50 | - name: Run tests without typechecking 51 | run: docker compose run no_check_tests 52 | -------------------------------------------------------------------------------- /.github/workflows/publish_jsr.yml: -------------------------------------------------------------------------------- 1 | name: Publish to JSR 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | publish: 10 | runs-on: ubuntu-latest 11 | timeout-minutes: 30 12 | 13 | permissions: 14 | contents: write 15 | id-token: write 16 | 17 | steps: 18 | - name: Checkout repository 19 | uses: actions/checkout@v4 20 | with: 21 | fetch-depth: 0 22 | 23 | - name: Set up Deno 24 | uses: denoland/setup-deno@v1 25 | with: 26 | deno-version: v2.x 27 | 28 | - name: Extract version from deno.json 29 | id: get_version 30 | run: | 31 | VERSION=$(jq -r .version < deno.json) 32 | echo "version=$VERSION" >> $GITHUB_OUTPUT 33 | 34 | - name: Check if version tag already exists 35 | run: | 36 | TAG="v${{ steps.get_version.outputs.version }}" 37 | if git rev-parse "$TAG" >/dev/null 2>&1; then 38 | echo "🚫 Tag $TAG already exists. Aborting." 39 | exit 1 40 | fi 41 | 42 | - name: Check Format 43 | run: deno fmt --check 44 | 45 | - name: Format 46 | run: deno fmt 47 | 48 | - name: Lint 49 | run: deno lint 50 | 51 | - name: Build tests container 52 | run: docker compose build tests 53 | 54 | - name: Run tests 55 | run: docker compose run tests 56 | 57 | - name: Run doc tests 58 | run: docker compose run doc_tests 59 | 60 | - name: Create tag for release 61 | run: | 62 | TAG="v${{ steps.get_version.outputs.version }}" 63 | git config user.name "github-actions" 64 | git config user.email "github-actions@users.noreply.github.com" 65 | git tag "$TAG" 66 | git push origin "$TAG" 67 | 68 | - name: Create GitHub Release 69 | run: | 70 | gh release create "v${{ steps.get_version.outputs.version }}" \ 71 | --title "v${{ steps.get_version.outputs.version }}" 72 | env: 73 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 74 | 75 | - name: Publish package 76 | run: deno publish 77 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .vscode/ 3 | package*.json -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM denoland/deno:alpine-2.2.11 2 | WORKDIR /app 3 | 4 | # Install wait utility 5 | USER root 6 | ADD https://github.com/ufoscout/docker-compose-wait/releases/download/2.12.1/wait /wait 7 | RUN chmod +x /wait 8 | 9 | USER deno 10 | 11 | # Cache external libraries 12 | # Test deps caches all main dependencies as well 13 | COPY tests/test_deps.ts tests/test_deps.ts 14 | RUN deno cache tests/test_deps.ts 15 | 16 | ADD . . 17 | RUN deno cache mod.ts 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018-2025 Bartłomiej Iwańczuk, Steven Guerrero, and Hector Ayala 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | # deno-postgres 4 | 5 | 6 |
7 | 8 |
9 | 10 | ![Build Status](https://img.shields.io/github/actions/workflow/status/denodrivers/postgres/ci.yml?branch=main&label=Build&logo=github&style=flat-square) 11 | [![Discord server](https://img.shields.io/discord/768918486575480863?color=blue&label=Join%20us&logo=discord&style=flat-square)](https://discord.com/invite/HEdTCvZUSf) 12 | [![JSR](https://jsr.io/badges/@db/postgres?style=flat-square)](https://jsr.io/@db/postgres) 13 | [![JSR Score](https://jsr.io/badges/@db/postgres/score?style=flat-square)](https://jsr.io/@db/postgres) 14 | [![Manual](https://img.shields.io/github/v/release/denodrivers/postgres?color=orange&label=Manual&logo=deno&style=flat-square)](https://deno-postgres.com) 15 | [![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) 16 | [![License](https://img.shields.io/github/license/denodrivers/postgres?color=yellowgreen&label=License&style=flat-square)](LICENSE) 17 | 18 | A lightweight PostgreSQL driver for Deno focused on developer experience.\ 19 | `deno-postgres` is inspired by the excellent work of 20 | [node-postgres](https://github.com/brianc/node-postgres) and 21 | [pq](https://github.com/lib/pq). 22 | 23 |
24 | 25 | ## Documentation 26 | 27 | The documentation is available on the 28 | [`deno-postgres`](https://deno-postgres.com/) website. 29 | 30 | Join the [Discord](https://discord.com/invite/HEdTCvZUSf) as well! It's a good 31 | place to discuss bugs and features before opening issues. 32 | 33 | ## Examples 34 | 35 | ```ts 36 | // deno run --allow-net --allow-read mod.ts 37 | import { Client } from "jsr:@db/postgres"; 38 | 39 | const client = new Client({ 40 | user: "user", 41 | database: "test", 42 | hostname: "localhost", 43 | port: 5432, 44 | }); 45 | 46 | await client.connect(); 47 | 48 | { 49 | const result = await client.queryArray("SELECT ID, NAME FROM PEOPLE"); 50 | console.log(result.rows); // [[1, 'Carlos'], [2, 'John'], ...] 51 | } 52 | 53 | { 54 | const result = await client 55 | .queryArray`SELECT ID, NAME FROM PEOPLE WHERE ID = ${1}`; 56 | console.log(result.rows); // [[1, 'Carlos']] 57 | } 58 | 59 | { 60 | const result = await client.queryObject("SELECT ID, NAME FROM PEOPLE"); 61 | console.log(result.rows); // [{id: 1, name: 'Carlos'}, {id: 2, name: 'Johnru'}, ...] 62 | } 63 | 64 | { 65 | const result = await client 66 | .queryObject`SELECT ID, NAME FROM PEOPLE WHERE ID = ${1}`; 67 | console.log(result.rows); // [{id: 1, name: 'Carlos'}] 68 | } 69 | 70 | await client.end(); 71 | ``` 72 | 73 | ## Deno compatibility 74 | 75 | Due to breaking changes introduced in the unstable APIs `deno-postgres` uses, 76 | there has been some fragmentation regarding what versions of Deno can be used 77 | alongside the driver. 78 | 79 | This situation will stabilize as `deno-postgres` approach version 1.0. 80 | 81 | | Deno version | Min driver version | Max version | Note | 82 | | ------------- | ------------------ | ----------- | -------------------------------------------------------------------------- | 83 | | 1.8.x | 0.5.0 | 0.10.0 | | 84 | | 1.9.0 | 0.11.0 | 0.11.1 | | 85 | | 1.9.1 and up | 0.11.2 | 0.11.3 | | 86 | | 1.11.0 and up | 0.12.0 | 0.12.0 | | 87 | | 1.14.0 and up | 0.13.0 | 0.13.0 | | 88 | | 1.16.0 | 0.14.0 | 0.14.3 | | 89 | | 1.17.0 | 0.15.0 | 0.17.1 | | 90 | | 1.40.0 | 0.17.2 | 0.19.3 | 0.19.3 and down are available in [deno.land](https://deno.land/x/postgres) | 91 | | 2.0.0 and up | 0.19.4 | - | Available on JSR! [`@db/postgres`](https://jsr.io/@db/postgres) | 92 | 93 | ## Breaking changes 94 | 95 | Although `deno-postgres` is reasonably stable and robust, it is a WIP, and we're 96 | still exploring the design. Expect some breaking changes as we reach version 1.0 97 | and enhance the feature set. Please check the 98 | [Releases](https://github.com/denodrivers/postgres/releases) for more info on 99 | breaking changes. Please reach out if there are any undocumented breaking 100 | changes. 101 | 102 | ## Found issues? 103 | 104 | Please 105 | [file an issue](https://github.com/denodrivers/postgres/issues/new/choose) with 106 | any problems with the driver. If you would like to help, please look at the 107 | issues as well. You can pick up one of them and try to implement it. 108 | 109 | ## Contributing 110 | 111 | ### Prerequisites 112 | 113 | - You must have `docker` and `docker-compose` installed on your machine 114 | 115 | - https://docs.docker.com/get-docker/ 116 | - https://docs.docker.com/compose/install/ 117 | 118 | - You don't need `deno` installed in your machine to run the tests since it will 119 | be installed in the Docker container when you build it. However, you will need 120 | it to run the linter and formatter locally 121 | 122 | - https://deno.land/ 123 | - `deno upgrade stable` 124 | - `dvm install stable && dvm use stable` 125 | 126 | - You don't need to install Postgres locally on your machine to test the 127 | library; it will run as a service in the Docker container when you build it 128 | 129 | ### Running the tests 130 | 131 | The tests are found under the `./tests` folder, and they are based on query 132 | result assertions. 133 | 134 | To run the tests, run the following commands: 135 | 136 | 1. `docker compose build tests` 137 | 2. `docker compose run tests` 138 | 139 | The build step will check linting and formatting as well and report it to the 140 | command line 141 | 142 | It is recommended that you don't rely on any previously initialized data for 143 | your tests instead create all the data you need at the moment of running the 144 | tests 145 | 146 | For example, the following test will create a temporary table that will 147 | disappear once the test has been completed 148 | 149 | ```ts 150 | Deno.test("INSERT works correctly", async () => { 151 | await client.queryArray(`CREATE TEMP TABLE MY_TEST (X INTEGER);`); 152 | await client.queryArray(`INSERT INTO MY_TEST (X) VALUES (1);`); 153 | const result = await client.queryObject<{ x: number }>({ 154 | text: `SELECT X FROM MY_TEST`, 155 | fields: ["x"], 156 | }); 157 | assertEquals(result.rows[0].x, 1); 158 | }); 159 | ``` 160 | 161 | ### Setting up an advanced development environment 162 | 163 | More advanced features, such as the Deno inspector, test, and permission 164 | filtering, database inspection, and test code lens can be achieved by setting up 165 | a local testing environment, as shown in the following steps: 166 | 167 | 1. Start the development databases using the Docker service with the command\ 168 | `docker-compose up postgres_clear postgres_md5 postgres_scram`\ 169 | Though using the detach (`-d`) option is recommended, this will make the 170 | databases run in the background unless you use docker itself to stop them. 171 | You can find more info about this 172 | [here](https://docs.docker.com/compose/reference/up) 173 | 2. Set the `DENO_POSTGRES_DEVELOPMENT` environmental variable to true, either by 174 | prepending it before the test command (on Linux) or setting it globally for 175 | all environments 176 | 177 | The `DENO_POSTGRES_DEVELOPMENT` variable will tell the testing pipeline to 178 | use the local testing settings specified in `tests/config.json` instead of 179 | the CI settings. 180 | 181 | 3. Run the tests manually by using the command\ 182 | `deno test -A` 183 | 184 | ## Contributing guidelines 185 | 186 | When contributing to the repository, make sure to: 187 | 188 | 1. All features and fixes must have an open issue to be discussed 189 | 2. All public interfaces must be typed and have a corresponding JSDoc block 190 | explaining their usage 191 | 3. All code must pass the format and lint checks enforced by `deno fmt` and 192 | `deno lint` respectively. The build will only pass the tests if these 193 | conditions are met. Ignore rules will be accepted in the code base when their 194 | respective justification is given in a comment 195 | 4. All features and fixes must have a corresponding test added to be accepted 196 | 197 | ## Maintainers guidelines 198 | 199 | When publishing a new version, ensure that the `version` field in `deno.json` 200 | has been updated to match the new version. 201 | 202 | ## License 203 | 204 | There are substantial parts of this library based on other libraries. They have 205 | preserved their individual licenses and copyrights. 206 | 207 | Everything is licensed under the MIT License. 208 | 209 | All additional work is copyright 2018 - 2025 — Bartłomiej Iwańczuk, Steven 210 | Guerrero, Hector Ayala — All rights reserved. 211 | -------------------------------------------------------------------------------- /client/error.ts: -------------------------------------------------------------------------------- 1 | import type { Notice } from "../connection/message.ts"; 2 | 3 | /** 4 | * A connection error 5 | */ 6 | export class ConnectionError extends Error { 7 | /** 8 | * Create a new ConnectionError 9 | */ 10 | constructor(message?: string) { 11 | super(message); 12 | this.name = "ConnectionError"; 13 | } 14 | } 15 | 16 | /** 17 | * A connection params error 18 | */ 19 | export class ConnectionParamsError extends Error { 20 | /** 21 | * Create a new ConnectionParamsError 22 | */ 23 | constructor(message: string, cause?: unknown) { 24 | super(message, { cause }); 25 | this.name = "ConnectionParamsError"; 26 | } 27 | } 28 | 29 | /** 30 | * A Postgres database error 31 | */ 32 | export class PostgresError extends Error { 33 | /** 34 | * The fields of the notice message 35 | */ 36 | public fields: Notice; 37 | 38 | /** 39 | * The query that caused the error 40 | */ 41 | public query: string | undefined; 42 | 43 | /** 44 | * Create a new PostgresError 45 | */ 46 | constructor(fields: Notice, query?: string) { 47 | super(fields.message); 48 | this.fields = fields; 49 | this.query = query; 50 | this.name = "PostgresError"; 51 | } 52 | } 53 | 54 | /** 55 | * A transaction error 56 | */ 57 | export class TransactionError extends Error { 58 | /** 59 | * Create a transaction error with a message and a cause 60 | */ 61 | constructor(transaction_name: string, cause: PostgresError) { 62 | super(`The transaction "${transaction_name}" has been aborted`, { cause }); 63 | this.name = "TransactionError"; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /connection/auth.ts: -------------------------------------------------------------------------------- 1 | import { crypto } from "@std/crypto/crypto"; 2 | import { encodeHex } from "@std/encoding/hex"; 3 | 4 | const encoder = new TextEncoder(); 5 | 6 | async function md5(bytes: Uint8Array): Promise { 7 | return encodeHex(await crypto.subtle.digest("MD5", bytes)); 8 | } 9 | 10 | // AuthenticationMD5Password 11 | // The actual PasswordMessage can be computed in SQL as: 12 | // concat('md5', md5(concat(md5(concat(password, username)), random-salt))). 13 | // (Keep in mind the md5() function returns its result as a hex string.) 14 | export async function hashMd5Password( 15 | password: string, 16 | username: string, 17 | salt: Uint8Array, 18 | ): Promise { 19 | const innerHash = await md5(encoder.encode(password + username)); 20 | const innerBytes = encoder.encode(innerHash); 21 | const outerBuffer = new Uint8Array(innerBytes.length + salt.length); 22 | outerBuffer.set(innerBytes); 23 | outerBuffer.set(salt, innerBytes.length); 24 | const outerHash = await md5(outerBuffer); 25 | return "md5" + outerHash; 26 | } 27 | -------------------------------------------------------------------------------- /connection/connection_params.ts: -------------------------------------------------------------------------------- 1 | import { parseConnectionUri } from "../utils/utils.ts"; 2 | import { ConnectionParamsError } from "../client/error.ts"; 3 | import { fromFileUrl, isAbsolute } from "@std/path"; 4 | import type { OidType } from "../query/oid.ts"; 5 | import type { DebugControls } from "../debug.ts"; 6 | import type { ParseArrayFunction } from "../query/array_parser.ts"; 7 | 8 | /** 9 | * The connection string must match the following URI structure. All parameters but database and user are optional 10 | * 11 | * `postgres://user:password@hostname:port/database?sslmode=mode...` 12 | * 13 | * You can additionally provide the following url search parameters 14 | * 15 | * - application_name 16 | * - dbname 17 | * - host 18 | * - options 19 | * - password 20 | * - port 21 | * - sslmode 22 | * - user 23 | */ 24 | export type ConnectionString = string; 25 | 26 | /** 27 | * Retrieves the connection options from the environmental variables 28 | * as they are, without any extra parsing 29 | * 30 | * It will throw if no env permission was provided on startup 31 | */ 32 | function getPgEnv(): ClientOptions { 33 | return { 34 | applicationName: Deno.env.get("PGAPPNAME"), 35 | database: Deno.env.get("PGDATABASE"), 36 | hostname: Deno.env.get("PGHOST"), 37 | options: Deno.env.get("PGOPTIONS"), 38 | password: Deno.env.get("PGPASSWORD"), 39 | port: Deno.env.get("PGPORT"), 40 | user: Deno.env.get("PGUSER"), 41 | }; 42 | } 43 | 44 | /** Additional granular database connection options */ 45 | export interface ConnectionOptions { 46 | /** 47 | * By default, any client will only attempt to stablish 48 | * connection with your database once. Setting this parameter 49 | * will cause the client to attempt reconnection as many times 50 | * as requested before erroring 51 | * 52 | * default: `1` 53 | */ 54 | attempts: number; 55 | /** 56 | * The time to wait before attempting each reconnection (in milliseconds) 57 | * 58 | * You can provide a fixed number or a function to call each time the 59 | * connection is attempted. By default, the interval will be a function 60 | * with an exponential backoff increasing by 500 milliseconds 61 | */ 62 | interval: number | ((previous_interval: number) => number); 63 | } 64 | 65 | /** https://www.postgresql.org/docs/14/libpq-ssl.html#LIBPQ-SSL-PROTECTION */ 66 | type TLSModes = "disable" | "prefer" | "require" | "verify-ca" | "verify-full"; 67 | 68 | /** The Transport Layer Security (TLS) protocol options to be used by the database connection */ 69 | export interface TLSOptions { 70 | // TODO 71 | // Refactor enabled and enforce into one single option for 1.0 72 | /** 73 | * If TLS support is enabled or not. If the server requires TLS, 74 | * the connection will fail. 75 | * 76 | * Default: `true` 77 | */ 78 | enabled: boolean; 79 | /** 80 | * Forces the connection to run over TLS 81 | * If the server doesn't support TLS, the connection will fail 82 | * 83 | * Default: `false` 84 | */ 85 | enforce: boolean; 86 | /** 87 | * A list of root certificates that will be used in addition to the default 88 | * root certificates to verify the server's certificate. 89 | * 90 | * Must be in PEM format. 91 | * 92 | * Default: `[]` 93 | */ 94 | caCertificates: string[]; 95 | } 96 | 97 | /** 98 | * The strategy to use when decoding results data 99 | */ 100 | export type DecodeStrategy = "string" | "auto"; 101 | /** 102 | * A dictionary of functions used to decode (parse) column field values from string to a custom type. These functions will 103 | * take precedence over the {@linkcode DecodeStrategy}. Each key in the dictionary is the column OID type number or Oid type name, 104 | * and the value is the decoder function. 105 | */ 106 | export type Decoders = { 107 | [key in number | OidType]?: DecoderFunction; 108 | }; 109 | 110 | /** 111 | * A decoder function that takes a string value and returns a parsed value of some type. 112 | * 113 | * @param value The string value to parse 114 | * @param oid The OID of the column type the value is from 115 | * @param parseArray A helper function that parses SQL array-formatted strings and parses each array value using a transform function. 116 | */ 117 | export type DecoderFunction = ( 118 | value: string, 119 | oid: number, 120 | parseArray: ParseArrayFunction, 121 | ) => unknown; 122 | 123 | /** 124 | * Control the behavior for the client instance 125 | */ 126 | export type ClientControls = { 127 | /** 128 | * Debugging options 129 | */ 130 | debug?: DebugControls; 131 | /** 132 | * The strategy to use when decoding results data 133 | * 134 | * `string` : all values are returned as string, and the user has to take care of parsing 135 | * `auto` : deno-postgres parses the data into JS objects (as many as possible implemented, non-implemented parsers would still return strings) 136 | * 137 | * Default: `auto` 138 | * 139 | * Future strategies might include: 140 | * - `strict` : deno-postgres parses the data into JS objects, and if a parser is not implemented, it throws an error 141 | * - `raw` : the data is returned as Uint8Array 142 | */ 143 | decodeStrategy?: DecodeStrategy; 144 | 145 | /** 146 | * A dictionary of functions used to decode (parse) column field values from string to a custom type. These functions will 147 | * take precedence over the {@linkcode ClientControls.decodeStrategy}. Each key in the dictionary is the column OID type number, and the value is 148 | * the decoder function. You can use the `Oid` object to set the decoder functions. 149 | * 150 | * @example 151 | * ```ts 152 | * import { Oid, Decoders } from '../mod.ts' 153 | * 154 | * { 155 | * const decoders: Decoders = { 156 | * // 16 = Oid.bool : convert all boolean values to numbers 157 | * '16': (value: string) => value === 't' ? 1 : 0, 158 | * // 1082 = Oid.date : convert all dates to Date objects 159 | * 1082: (value: string) => new Date(value), 160 | * // 23 = Oid.int4 : convert all integers to positive numbers 161 | * [Oid.int4]: (value: string) => Math.max(0, parseInt(value || '0', 10)), 162 | * } 163 | * } 164 | * ``` 165 | */ 166 | decoders?: Decoders; 167 | }; 168 | 169 | /** The Client database connection options */ 170 | export type ClientOptions = { 171 | /** Name of the application connecing to the database */ 172 | applicationName?: string; 173 | /** Additional connection options */ 174 | connection?: Partial; 175 | /** Control the client behavior */ 176 | controls?: ClientControls; 177 | /** The database name */ 178 | database?: string; 179 | /** The name of the host */ 180 | hostname?: string; 181 | /** The type of host connection */ 182 | host_type?: "tcp" | "socket"; 183 | /** 184 | * Additional connection URI options 185 | * https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-PARAMKEYWORDS 186 | */ 187 | options?: string | Record; 188 | /** The database user password */ 189 | password?: string; 190 | /** The database port used by the connection */ 191 | port?: string | number; 192 | /** */ 193 | tls?: Partial; 194 | /** The database user */ 195 | user?: string; 196 | }; 197 | 198 | /** The configuration options required to set up a Client instance */ 199 | export type ClientConfiguration = 200 | & Required< 201 | Omit< 202 | ClientOptions, 203 | "password" | "port" | "tls" | "connection" | "options" | "controls" 204 | > 205 | > 206 | & { 207 | connection: ConnectionOptions; 208 | controls?: ClientControls; 209 | options: Record; 210 | password?: string; 211 | port: number; 212 | tls: TLSOptions; 213 | }; 214 | 215 | function formatMissingParams(missingParams: string[]) { 216 | return `Missing connection parameters: ${missingParams.join(", ")}`; 217 | } 218 | 219 | /** 220 | * Validates the options passed are defined and have a value other than null 221 | * or empty string, it throws a connection error otherwise 222 | * 223 | * @param has_env_access This parameter will change the error message if set to true, 224 | * telling the user to pass env permissions in order to read environmental variables 225 | */ 226 | function assertRequiredOptions( 227 | options: Partial, 228 | requiredKeys: (keyof ClientOptions)[], 229 | has_env_access: boolean, 230 | ): asserts options is ClientConfiguration { 231 | const missingParams: (keyof ClientOptions)[] = []; 232 | for (const key of requiredKeys) { 233 | if ( 234 | options[key] === "" || 235 | options[key] === null || 236 | options[key] === undefined 237 | ) { 238 | missingParams.push(key); 239 | } 240 | } 241 | 242 | if (missingParams.length) { 243 | let missing_params_message = formatMissingParams(missingParams); 244 | if (!has_env_access) { 245 | missing_params_message += 246 | "\nConnection parameters can be read from environment variables only if Deno is run with env permission"; 247 | } 248 | 249 | throw new ConnectionParamsError(missing_params_message); 250 | } 251 | } 252 | 253 | // TODO 254 | // Support more options from the spec 255 | /** options from URI per https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNSTRING */ 256 | interface PostgresUri { 257 | application_name?: string; 258 | dbname?: string; 259 | driver: string; 260 | host?: string; 261 | options?: string; 262 | password?: string; 263 | port?: string; 264 | sslmode?: TLSModes; 265 | user?: string; 266 | } 267 | 268 | function parseOptionsArgument(options: string): Record { 269 | const args = options.split(" "); 270 | 271 | const transformed_args = []; 272 | for (let x = 0; x < args.length; x++) { 273 | if (/^-\w/.test(args[x])) { 274 | if (args[x] === "-c") { 275 | if (args[x + 1] === undefined) { 276 | throw new Error( 277 | `No provided value for "${args[x]}" in options parameter`, 278 | ); 279 | } 280 | 281 | // Skip next iteration 282 | transformed_args.push(args[x + 1]); 283 | x++; 284 | } else { 285 | throw new Error( 286 | `Argument "${args[x]}" is not supported in options parameter`, 287 | ); 288 | } 289 | } else if (/^--\w/.test(args[x])) { 290 | transformed_args.push(args[x].slice(2)); 291 | } else { 292 | throw new Error(`Value "${args[x]}" is not a valid options argument`); 293 | } 294 | } 295 | 296 | return transformed_args.reduce((options, x) => { 297 | if (!/.+=.+/.test(x)) { 298 | throw new Error(`Value "${x}" is not a valid options argument`); 299 | } 300 | 301 | const key = x.slice(0, x.indexOf("=")); 302 | const value = x.slice(x.indexOf("=") + 1); 303 | 304 | options[key] = value; 305 | 306 | return options; 307 | }, {} as Record); 308 | } 309 | 310 | function parseOptionsFromUri(connection_string: string): ClientOptions { 311 | let postgres_uri: PostgresUri; 312 | try { 313 | const uri = parseConnectionUri(connection_string); 314 | postgres_uri = { 315 | application_name: uri.params.application_name, 316 | dbname: uri.path || uri.params.dbname, 317 | driver: uri.driver, 318 | host: uri.host || uri.params.host, 319 | options: uri.params.options, 320 | password: uri.password || uri.params.password, 321 | port: uri.port || uri.params.port, 322 | // Compatibility with JDBC, not standard 323 | // Treat as sslmode=require 324 | sslmode: uri.params.ssl === "true" 325 | ? "require" 326 | : (uri.params.sslmode as TLSModes), 327 | user: uri.user || uri.params.user, 328 | }; 329 | } catch (e) { 330 | throw new ConnectionParamsError("Could not parse the connection string", e); 331 | } 332 | 333 | if (!["postgres", "postgresql"].includes(postgres_uri.driver)) { 334 | throw new ConnectionParamsError( 335 | `Supplied DSN has invalid driver: ${postgres_uri.driver}.`, 336 | ); 337 | } 338 | 339 | // No host by default means socket connection 340 | const host_type = postgres_uri.host 341 | ? isAbsolute(postgres_uri.host) ? "socket" : "tcp" 342 | : "socket"; 343 | 344 | const options = postgres_uri.options 345 | ? parseOptionsArgument(postgres_uri.options) 346 | : {}; 347 | 348 | let tls: TLSOptions | undefined; 349 | switch (postgres_uri.sslmode) { 350 | case undefined: { 351 | break; 352 | } 353 | case "disable": { 354 | tls = { enabled: false, enforce: false, caCertificates: [] }; 355 | break; 356 | } 357 | case "prefer": { 358 | tls = { enabled: true, enforce: false, caCertificates: [] }; 359 | break; 360 | } 361 | case "require": 362 | case "verify-ca": 363 | case "verify-full": { 364 | tls = { enabled: true, enforce: true, caCertificates: [] }; 365 | break; 366 | } 367 | default: { 368 | throw new ConnectionParamsError( 369 | `Supplied DSN has invalid sslmode '${postgres_uri.sslmode}'`, 370 | ); 371 | } 372 | } 373 | 374 | return { 375 | applicationName: postgres_uri.application_name, 376 | database: postgres_uri.dbname, 377 | hostname: postgres_uri.host, 378 | host_type, 379 | options, 380 | password: postgres_uri.password, 381 | port: postgres_uri.port, 382 | tls, 383 | user: postgres_uri.user, 384 | }; 385 | } 386 | 387 | const DEFAULT_OPTIONS: 388 | & Omit< 389 | ClientConfiguration, 390 | "database" | "user" | "hostname" 391 | > 392 | & { host: string; socket: string } = { 393 | applicationName: "deno_postgres", 394 | connection: { 395 | attempts: 1, 396 | interval: (previous_interval) => previous_interval + 500, 397 | }, 398 | host: "127.0.0.1", 399 | socket: "/tmp", 400 | host_type: "socket", 401 | options: {}, 402 | port: 5432, 403 | tls: { 404 | enabled: true, 405 | enforce: false, 406 | caCertificates: [], 407 | }, 408 | }; 409 | 410 | export function createParams( 411 | params: string | ClientOptions = {}, 412 | ): ClientConfiguration { 413 | if (typeof params === "string") { 414 | params = parseOptionsFromUri(params); 415 | } 416 | 417 | let pgEnv: ClientOptions = {}; 418 | let has_env_access = true; 419 | try { 420 | pgEnv = getPgEnv(); 421 | } catch (e) { 422 | // In Deno v1, Deno permission errors resulted in a Deno.errors.PermissionDenied exception. In Deno v2, a new 423 | // Deno.errors.NotCapable exception was added to replace this. The "in" check makes this code safe for both Deno 424 | // 1 and Deno 2 425 | if ( 426 | e instanceof 427 | ("NotCapable" in Deno.errors 428 | ? Deno.errors.NotCapable 429 | : Deno.errors.PermissionDenied) 430 | ) { 431 | has_env_access = false; 432 | } else { 433 | throw e; 434 | } 435 | } 436 | 437 | const provided_host = params.hostname ?? pgEnv.hostname; 438 | 439 | // If a host is provided, the default connection type is TCP 440 | const host_type = params.host_type ?? 441 | (provided_host ? "tcp" : DEFAULT_OPTIONS.host_type); 442 | if (!["tcp", "socket"].includes(host_type)) { 443 | throw new ConnectionParamsError(`"${host_type}" is not a valid host type`); 444 | } 445 | 446 | let host: string; 447 | if (host_type === "socket") { 448 | const socket = provided_host ?? DEFAULT_OPTIONS.socket; 449 | try { 450 | if (!isAbsolute(socket)) { 451 | const parsed_host = new URL(socket, Deno.mainModule); 452 | 453 | // Resolve relative path 454 | if (parsed_host.protocol === "file:") { 455 | host = fromFileUrl(parsed_host); 456 | } else { 457 | throw new Error("The provided host is not a file path"); 458 | } 459 | } else { 460 | host = socket; 461 | } 462 | } catch (e) { 463 | throw new ConnectionParamsError(`Could not parse host "${socket}"`, e); 464 | } 465 | } else { 466 | host = provided_host ?? DEFAULT_OPTIONS.host; 467 | } 468 | 469 | const provided_options = params.options ?? pgEnv.options; 470 | 471 | let options: Record; 472 | if (provided_options) { 473 | if (typeof provided_options === "string") { 474 | options = parseOptionsArgument(provided_options); 475 | } else { 476 | options = provided_options; 477 | } 478 | } else { 479 | options = {}; 480 | } 481 | 482 | for (const key in options) { 483 | if (!/^\w+$/.test(key)) { 484 | throw new Error(`The "${key}" key in the options argument is invalid`); 485 | } 486 | 487 | options[key] = options[key].replaceAll(" ", "\\ "); 488 | } 489 | 490 | let port: number; 491 | if (params.port) { 492 | port = Number(params.port); 493 | } else if (pgEnv.port) { 494 | port = Number(pgEnv.port); 495 | } else { 496 | port = Number(DEFAULT_OPTIONS.port); 497 | } 498 | if (Number.isNaN(port) || port === 0) { 499 | throw new ConnectionParamsError( 500 | `"${params.port ?? pgEnv.port}" is not a valid port number`, 501 | ); 502 | } 503 | 504 | if (host_type === "socket" && params?.tls) { 505 | throw new ConnectionParamsError( 506 | 'No TLS options are allowed when host type is set to "socket"', 507 | ); 508 | } 509 | const tls_enabled = !!(params?.tls?.enabled ?? DEFAULT_OPTIONS.tls.enabled); 510 | const tls_enforced = !!(params?.tls?.enforce ?? DEFAULT_OPTIONS.tls.enforce); 511 | 512 | if (!tls_enabled && tls_enforced) { 513 | throw new ConnectionParamsError( 514 | "Can't enforce TLS when client has TLS encryption is disabled", 515 | ); 516 | } 517 | 518 | // TODO 519 | // Perhaps username should be taken from the PC user as a default? 520 | const connection_options = { 521 | applicationName: params.applicationName ?? 522 | pgEnv.applicationName ?? 523 | DEFAULT_OPTIONS.applicationName, 524 | connection: { 525 | attempts: params?.connection?.attempts ?? 526 | DEFAULT_OPTIONS.connection.attempts, 527 | interval: params?.connection?.interval ?? 528 | DEFAULT_OPTIONS.connection.interval, 529 | }, 530 | database: params.database ?? pgEnv.database, 531 | hostname: host, 532 | host_type, 533 | options, 534 | password: params.password ?? pgEnv.password, 535 | port, 536 | tls: { 537 | enabled: tls_enabled, 538 | enforce: tls_enforced, 539 | caCertificates: params?.tls?.caCertificates ?? [], 540 | }, 541 | user: params.user ?? pgEnv.user, 542 | controls: params.controls, 543 | }; 544 | 545 | assertRequiredOptions( 546 | connection_options, 547 | ["applicationName", "database", "hostname", "host_type", "port", "user"], 548 | has_env_access, 549 | ); 550 | 551 | return connection_options; 552 | } 553 | -------------------------------------------------------------------------------- /connection/message.ts: -------------------------------------------------------------------------------- 1 | import { Column } from "../query/decode.ts"; 2 | import { PacketReader } from "./packet.ts"; 3 | import { RowDescription } from "../query/query.ts"; 4 | 5 | export class Message { 6 | public reader: PacketReader; 7 | 8 | constructor( 9 | public type: string, 10 | public byteCount: number, 11 | public body: Uint8Array, 12 | ) { 13 | this.reader = new PacketReader(body); 14 | } 15 | } 16 | 17 | /** 18 | * The notice interface defining the fields of a notice message 19 | */ 20 | export interface Notice { 21 | /** The notice severity level */ 22 | severity: string; 23 | /** The notice code */ 24 | code: string; 25 | /** The notice message */ 26 | message: string; 27 | /** The additional notice detail */ 28 | detail?: string; 29 | /** The notice hint descrip=bing possible ways to fix this notice */ 30 | hint?: string; 31 | /** The position of code that triggered the notice */ 32 | position?: string; 33 | /** The internal position of code that triggered the notice */ 34 | internalPosition?: string; 35 | /** The internal query that triggered the notice */ 36 | internalQuery?: string; 37 | /** The where metadata */ 38 | where?: string; 39 | /** The database schema */ 40 | schema?: string; 41 | /** The table name */ 42 | table?: string; 43 | /** The column name */ 44 | column?: string; 45 | /** The data type name */ 46 | dataType?: string; 47 | /** The constraint name */ 48 | constraint?: string; 49 | /** The file name */ 50 | file?: string; 51 | /** The line number */ 52 | line?: string; 53 | /** The routine name */ 54 | routine?: string; 55 | } 56 | 57 | export function parseBackendKeyMessage(message: Message): { 58 | pid: number; 59 | secret_key: number; 60 | } { 61 | return { 62 | pid: message.reader.readInt32(), 63 | secret_key: message.reader.readInt32(), 64 | }; 65 | } 66 | 67 | /** 68 | * This function returns the command result tag from the command message 69 | */ 70 | export function parseCommandCompleteMessage(message: Message): string { 71 | return message.reader.readString(message.byteCount); 72 | } 73 | 74 | /** 75 | * https://www.postgresql.org/docs/14/protocol-error-fields.html 76 | */ 77 | export function parseNoticeMessage(message: Message): Notice { 78 | // deno-lint-ignore no-explicit-any 79 | const error_fields: any = {}; 80 | 81 | let byte: number; 82 | let field_code: string; 83 | let field_value: string; 84 | 85 | while ((byte = message.reader.readByte())) { 86 | field_code = String.fromCharCode(byte); 87 | field_value = message.reader.readCString(); 88 | 89 | switch (field_code) { 90 | case "S": 91 | error_fields.severity = field_value; 92 | break; 93 | case "C": 94 | error_fields.code = field_value; 95 | break; 96 | case "M": 97 | error_fields.message = field_value; 98 | break; 99 | case "D": 100 | error_fields.detail = field_value; 101 | break; 102 | case "H": 103 | error_fields.hint = field_value; 104 | break; 105 | case "P": 106 | error_fields.position = field_value; 107 | break; 108 | case "p": 109 | error_fields.internalPosition = field_value; 110 | break; 111 | case "q": 112 | error_fields.internalQuery = field_value; 113 | break; 114 | case "W": 115 | error_fields.where = field_value; 116 | break; 117 | case "s": 118 | error_fields.schema = field_value; 119 | break; 120 | case "t": 121 | error_fields.table = field_value; 122 | break; 123 | case "c": 124 | error_fields.column = field_value; 125 | break; 126 | case "d": 127 | error_fields.dataTypeName = field_value; 128 | break; 129 | case "n": 130 | error_fields.constraint = field_value; 131 | break; 132 | case "F": 133 | error_fields.file = field_value; 134 | break; 135 | case "L": 136 | error_fields.line = field_value; 137 | break; 138 | case "R": 139 | error_fields.routine = field_value; 140 | break; 141 | default: 142 | // from Postgres docs 143 | // > Since more field types might be added in future, 144 | // > frontends should silently ignore fields of unrecognized type. 145 | break; 146 | } 147 | } 148 | 149 | return error_fields; 150 | } 151 | 152 | /** 153 | * Parses a row data message into an array of bytes ready to be processed as column values 154 | */ 155 | // TODO 156 | // Research corner cases where parseRowData can return null values 157 | // deno-lint-ignore no-explicit-any 158 | export function parseRowDataMessage(message: Message): any[] { 159 | const field_count = message.reader.readInt16(); 160 | const row = []; 161 | 162 | for (let i = 0; i < field_count; i++) { 163 | const col_length = message.reader.readInt32(); 164 | 165 | if (col_length == -1) { 166 | row.push(null); 167 | continue; 168 | } 169 | 170 | // reading raw bytes here, they will be properly parsed later 171 | row.push(message.reader.readBytes(col_length)); 172 | } 173 | 174 | return row; 175 | } 176 | 177 | export function parseRowDescriptionMessage(message: Message): RowDescription { 178 | const column_count = message.reader.readInt16(); 179 | const columns = []; 180 | 181 | for (let i = 0; i < column_count; i++) { 182 | // TODO: if one of columns has 'format' == 'binary', 183 | // all of them will be in same format? 184 | const column = new Column( 185 | message.reader.readCString(), // name 186 | message.reader.readInt32(), // tableOid 187 | message.reader.readInt16(), // index 188 | message.reader.readInt32(), // dataTypeOid 189 | message.reader.readInt16(), // column 190 | message.reader.readInt32(), // typeModifier 191 | message.reader.readInt16(), // format 192 | ); 193 | columns.push(column); 194 | } 195 | 196 | return new RowDescription(column_count, columns); 197 | } 198 | -------------------------------------------------------------------------------- /connection/message_code.ts: -------------------------------------------------------------------------------- 1 | // https://www.postgresql.org/docs/14/protocol-message-formats.html 2 | 3 | export const ERROR_MESSAGE = "E"; 4 | 5 | export const AUTHENTICATION_TYPE = { 6 | CLEAR_TEXT: 3, 7 | GSS_CONTINUE: 8, 8 | GSS_STARTUP: 7, 9 | MD5: 5, 10 | NO_AUTHENTICATION: 0, 11 | SASL_CONTINUE: 11, 12 | SASL_FINAL: 12, 13 | SASL_STARTUP: 10, 14 | SCM: 6, 15 | SSPI: 9, 16 | } as const; 17 | 18 | export const INCOMING_QUERY_BIND_MESSAGES = {} as const; 19 | 20 | export const INCOMING_QUERY_PARSE_MESSAGES = {} as const; 21 | 22 | export const INCOMING_AUTHENTICATION_MESSAGES = { 23 | AUTHENTICATION: "R", 24 | BACKEND_KEY: "K", 25 | PARAMETER_STATUS: "S", 26 | READY: "Z", 27 | NOTICE: "N", 28 | } as const; 29 | 30 | export const INCOMING_TLS_MESSAGES = { 31 | ACCEPTS_TLS: "S", 32 | NO_ACCEPTS_TLS: "N", 33 | } as const; 34 | 35 | export const INCOMING_QUERY_MESSAGES = { 36 | BIND_COMPLETE: "2", 37 | COMMAND_COMPLETE: "C", 38 | DATA_ROW: "D", 39 | EMPTY_QUERY: "I", 40 | NOTICE_WARNING: "N", 41 | NO_DATA: "n", 42 | PARAMETER_STATUS: "S", 43 | PARSE_COMPLETE: "1", 44 | READY: "Z", 45 | ROW_DESCRIPTION: "T", 46 | } as const; 47 | -------------------------------------------------------------------------------- /connection/packet.ts: -------------------------------------------------------------------------------- 1 | /*! 2 | * Adapted directly from https://github.com/brianc/node-buffer-writer 3 | * which is licensed as follows: 4 | * 5 | * The MIT License (MIT) 6 | * 7 | * 8 | * Permission is hereby granted, free of charge, to any person obtaining 9 | * a copy of this software and associated documentation files (the 10 | * 'Software'), to deal in the Software without restriction, including 11 | * without limitation the rights to use, copy, modify, merge, publish, 12 | * distribute, sublicense, and/or sell copies of the Software, and to 13 | * permit persons to whom the Software is furnished to do so, subject to 14 | * the following conditions: 15 | * 16 | * The above copyright notice and this permission notice shall be 17 | * included in all copies or substantial portions of the Software. 18 | * 19 | * THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, 20 | * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 21 | * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 22 | * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 23 | * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 24 | * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 25 | * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 26 | */ 27 | 28 | import { copy } from "@std/bytes/copy"; 29 | import { readInt16BE, readInt32BE } from "../utils/utils.ts"; 30 | 31 | export class PacketReader { 32 | #buffer: Uint8Array; 33 | #decoder = new TextDecoder(); 34 | #offset = 0; 35 | 36 | constructor(buffer: Uint8Array) { 37 | this.#buffer = buffer; 38 | } 39 | 40 | readInt16(): number { 41 | const value = readInt16BE(this.#buffer, this.#offset); 42 | this.#offset += 2; 43 | return value; 44 | } 45 | 46 | readInt32(): number { 47 | const value = readInt32BE(this.#buffer, this.#offset); 48 | this.#offset += 4; 49 | return value; 50 | } 51 | 52 | readByte(): number { 53 | return this.readBytes(1)[0]; 54 | } 55 | 56 | readBytes(length: number): Uint8Array { 57 | const start = this.#offset; 58 | const end = start + length; 59 | const slice = this.#buffer.slice(start, end); 60 | this.#offset = end; 61 | return slice; 62 | } 63 | 64 | readAllBytes(): Uint8Array { 65 | const slice = this.#buffer.slice(this.#offset); 66 | this.#offset = this.#buffer.length; 67 | return slice; 68 | } 69 | 70 | readString(length: number): string { 71 | const bytes = this.readBytes(length); 72 | return this.#decoder.decode(bytes); 73 | } 74 | 75 | readCString(): string { 76 | const start = this.#offset; 77 | // find next null byte 78 | const end = this.#buffer.indexOf(0, start); 79 | const slice = this.#buffer.slice(start, end); 80 | // add +1 for null byte 81 | this.#offset = end + 1; 82 | return this.#decoder.decode(slice); 83 | } 84 | } 85 | 86 | export class PacketWriter { 87 | #buffer: Uint8Array; 88 | #encoder = new TextEncoder(); 89 | #headerPosition: number; 90 | #offset: number; 91 | #size: number; 92 | 93 | constructor(size?: number) { 94 | this.#size = size || 1024; 95 | this.#buffer = new Uint8Array(this.#size + 5); 96 | this.#offset = 5; 97 | this.#headerPosition = 0; 98 | } 99 | 100 | #ensure(size: number) { 101 | const remaining = this.#buffer.length - this.#offset; 102 | if (remaining < size) { 103 | const oldBuffer = this.#buffer; 104 | // exponential growth factor of around ~ 1.5 105 | // https://stackoverflow.com/questions/2269063/#buffer-growth-strategy 106 | const newSize = oldBuffer.length + (oldBuffer.length >> 1) + size; 107 | this.#buffer = new Uint8Array(newSize); 108 | copy(oldBuffer, this.#buffer); 109 | } 110 | } 111 | 112 | addInt32(num: number) { 113 | this.#ensure(4); 114 | this.#buffer[this.#offset++] = (num >>> 24) & 0xff; 115 | this.#buffer[this.#offset++] = (num >>> 16) & 0xff; 116 | this.#buffer[this.#offset++] = (num >>> 8) & 0xff; 117 | this.#buffer[this.#offset++] = (num >>> 0) & 0xff; 118 | return this; 119 | } 120 | 121 | addInt16(num: number) { 122 | this.#ensure(2); 123 | this.#buffer[this.#offset++] = (num >>> 8) & 0xff; 124 | this.#buffer[this.#offset++] = (num >>> 0) & 0xff; 125 | return this; 126 | } 127 | 128 | addCString(string?: string) { 129 | // just write a 0 for empty or null strings 130 | if (!string) { 131 | this.#ensure(1); 132 | } else { 133 | const encodedStr = this.#encoder.encode(string); 134 | this.#ensure(encodedStr.byteLength + 1); // +1 for null terminator 135 | copy(encodedStr, this.#buffer, this.#offset); 136 | this.#offset += encodedStr.byteLength; 137 | } 138 | 139 | this.#buffer[this.#offset++] = 0; // null terminator 140 | return this; 141 | } 142 | 143 | addChar(c: string) { 144 | if (c.length != 1) { 145 | throw new Error("addChar requires single character strings"); 146 | } 147 | 148 | this.#ensure(1); 149 | copy(this.#encoder.encode(c), this.#buffer, this.#offset); 150 | this.#offset++; 151 | return this; 152 | } 153 | 154 | addString(string?: string) { 155 | string = string || ""; 156 | const encodedStr = this.#encoder.encode(string); 157 | this.#ensure(encodedStr.byteLength); 158 | copy(encodedStr, this.#buffer, this.#offset); 159 | this.#offset += encodedStr.byteLength; 160 | return this; 161 | } 162 | 163 | add(otherBuffer: Uint8Array) { 164 | this.#ensure(otherBuffer.length); 165 | copy(otherBuffer, this.#buffer, this.#offset); 166 | this.#offset += otherBuffer.length; 167 | return this; 168 | } 169 | 170 | clear() { 171 | this.#offset = 5; 172 | this.#headerPosition = 0; 173 | } 174 | 175 | // appends a header block to all the written data since the last 176 | // subsequent header or to the beginning if there is only one data block 177 | addHeader(code: number, last?: boolean) { 178 | const origOffset = this.#offset; 179 | this.#offset = this.#headerPosition; 180 | this.#buffer[this.#offset++] = code; 181 | // length is everything in this packet minus the code 182 | this.addInt32(origOffset - (this.#headerPosition + 1)); 183 | // set next header position 184 | this.#headerPosition = origOffset; 185 | // make space for next header 186 | this.#offset = origOffset; 187 | if (!last) { 188 | this.#ensure(5); 189 | this.#offset += 5; 190 | } 191 | return this; 192 | } 193 | 194 | join(code?: number) { 195 | if (code) { 196 | this.addHeader(code, true); 197 | } 198 | return this.#buffer.slice(code ? 0 : 5, this.#offset); 199 | } 200 | 201 | flush(code?: number) { 202 | const result = this.join(code); 203 | this.clear(); 204 | return result; 205 | } 206 | } 207 | -------------------------------------------------------------------------------- /connection/scram.ts: -------------------------------------------------------------------------------- 1 | import { decodeBase64, encodeBase64 } from "@std/encoding/base64"; 2 | 3 | /** Number of random bytes used to generate a nonce */ 4 | const defaultNonceSize = 16; 5 | const text_encoder = new TextEncoder(); 6 | 7 | enum AuthenticationState { 8 | Init, 9 | ClientChallenge, 10 | ServerChallenge, 11 | ClientResponse, 12 | ServerResponse, 13 | Failed, 14 | } 15 | 16 | /** 17 | * Collection of SCRAM authentication keys derived from a plaintext password 18 | * in HMAC-derived binary format 19 | */ 20 | interface KeySignatures { 21 | client: Uint8Array; 22 | server: Uint8Array; 23 | stored: Uint8Array; 24 | } 25 | 26 | /** 27 | * Reason of authentication failure 28 | */ 29 | export enum Reason { 30 | BadMessage = "server sent an ill-formed message", 31 | BadServerNonce = "server sent an invalid nonce", 32 | BadSalt = "server specified an invalid salt", 33 | BadIterationCount = "server specified an invalid iteration count", 34 | BadVerifier = "server sent a bad verifier", 35 | Rejected = "rejected by server", 36 | } 37 | 38 | function assert(cond: unknown): asserts cond { 39 | if (!cond) { 40 | throw new Error("Scram protocol assertion failed"); 41 | } 42 | } 43 | 44 | // TODO 45 | // Handle mapping and maybe unicode normalization. 46 | // Add tests for invalid string values 47 | /** 48 | * Normalizes string per SASLprep. 49 | * @see {@link https://tools.ietf.org/html/rfc3454} 50 | * @see {@link https://tools.ietf.org/html/rfc4013} 51 | */ 52 | function assertValidScramString(str: string) { 53 | const unsafe = /[^\x21-\x7e]/; 54 | if (unsafe.test(str)) { 55 | throw new Error( 56 | "scram username/password is currently limited to safe ascii characters", 57 | ); 58 | } 59 | } 60 | 61 | async function computeScramSignature( 62 | message: string, 63 | raw_key: Uint8Array, 64 | ): Promise { 65 | const key = await crypto.subtle.importKey( 66 | "raw", 67 | raw_key, 68 | { name: "HMAC", hash: "SHA-256" }, 69 | false, 70 | ["sign"], 71 | ); 72 | 73 | return new Uint8Array( 74 | await crypto.subtle.sign( 75 | { name: "HMAC", hash: "SHA-256" }, 76 | key, 77 | text_encoder.encode(message), 78 | ), 79 | ); 80 | } 81 | 82 | function computeScramProof(signature: Uint8Array, key: Uint8Array): Uint8Array { 83 | const digest = new Uint8Array(signature.length); 84 | for (let i = 0; i < digest.length; i++) { 85 | digest[i] = signature[i] ^ key[i]; 86 | } 87 | return digest; 88 | } 89 | 90 | /** 91 | * Derives authentication key signatures from a plaintext password 92 | */ 93 | async function deriveKeySignatures( 94 | password: string, 95 | salt: Uint8Array, 96 | iterations: number, 97 | ): Promise { 98 | const pbkdf2_password = await crypto.subtle.importKey( 99 | "raw", 100 | text_encoder.encode(password), 101 | "PBKDF2", 102 | false, 103 | ["deriveBits", "deriveKey"], 104 | ); 105 | const key = await crypto.subtle.deriveKey( 106 | { 107 | hash: "SHA-256", 108 | iterations, 109 | name: "PBKDF2", 110 | salt, 111 | }, 112 | pbkdf2_password, 113 | { name: "HMAC", hash: "SHA-256", length: 256 }, 114 | false, 115 | ["sign"], 116 | ); 117 | 118 | const client = new Uint8Array( 119 | await crypto.subtle.sign("HMAC", key, text_encoder.encode("Client Key")), 120 | ); 121 | const server = new Uint8Array( 122 | await crypto.subtle.sign("HMAC", key, text_encoder.encode("Server Key")), 123 | ); 124 | const stored = new Uint8Array(await crypto.subtle.digest("SHA-256", client)); 125 | 126 | return { client, server, stored }; 127 | } 128 | 129 | /** Escapes "=" and "," in a string. */ 130 | function escape(str: string): string { 131 | return str.replace(/=/g, "=3D").replace(/,/g, "=2C"); 132 | } 133 | 134 | function generateRandomNonce(size: number): string { 135 | return encodeBase64(crypto.getRandomValues(new Uint8Array(size))); 136 | } 137 | 138 | function parseScramAttributes(message: string): Record { 139 | const attrs: Record = {}; 140 | 141 | for (const entry of message.split(",")) { 142 | const pos = entry.indexOf("="); 143 | if (pos < 1) { 144 | throw new Error(Reason.BadMessage); 145 | } 146 | 147 | const key = entry.substring(0, pos); 148 | const value = entry.slice(pos + 1); 149 | attrs[key] = value; 150 | } 151 | 152 | return attrs; 153 | } 154 | 155 | /** 156 | * Client composes and verifies SCRAM authentication messages, keeping track 157 | * of authentication #state and parameters. 158 | * @see {@link https://tools.ietf.org/html/rfc5802} 159 | */ 160 | export class Client { 161 | #auth_message: string; 162 | #client_nonce: string; 163 | #key_signatures?: KeySignatures; 164 | #password: string; 165 | #server_nonce?: string; 166 | #state: AuthenticationState; 167 | #username: string; 168 | 169 | constructor(username: string, password: string, nonce?: string) { 170 | assertValidScramString(password); 171 | assertValidScramString(username); 172 | 173 | this.#auth_message = ""; 174 | this.#client_nonce = nonce ?? generateRandomNonce(defaultNonceSize); 175 | this.#password = password; 176 | this.#state = AuthenticationState.Init; 177 | this.#username = escape(username); 178 | } 179 | 180 | /** 181 | * Composes client-first-message 182 | */ 183 | composeChallenge(): string { 184 | assert(this.#state === AuthenticationState.Init); 185 | 186 | try { 187 | // "n" for no channel binding, then an empty authzid option follows. 188 | const header = "n,,"; 189 | 190 | const challenge = `n=${this.#username},r=${this.#client_nonce}`; 191 | const message = header + challenge; 192 | 193 | this.#auth_message += challenge; 194 | this.#state = AuthenticationState.ClientChallenge; 195 | return message; 196 | } catch (e) { 197 | this.#state = AuthenticationState.Failed; 198 | throw e; 199 | } 200 | } 201 | 202 | /** 203 | * Processes server-first-message 204 | */ 205 | async receiveChallenge(challenge: string) { 206 | assert(this.#state === AuthenticationState.ClientChallenge); 207 | 208 | try { 209 | const attrs = parseScramAttributes(challenge); 210 | 211 | const nonce = attrs.r; 212 | if (!attrs.r || !attrs.r.startsWith(this.#client_nonce)) { 213 | throw new Error(Reason.BadServerNonce); 214 | } 215 | this.#server_nonce = nonce; 216 | 217 | let salt: Uint8Array | undefined; 218 | if (!attrs.s) { 219 | throw new Error(Reason.BadSalt); 220 | } 221 | try { 222 | salt = decodeBase64(attrs.s); 223 | } catch { 224 | throw new Error(Reason.BadSalt); 225 | } 226 | 227 | if (!salt) throw new Error(Reason.BadSalt); 228 | 229 | const iterCount = parseInt(attrs.i) | 0; 230 | if (iterCount <= 0) { 231 | throw new Error(Reason.BadIterationCount); 232 | } 233 | 234 | this.#key_signatures = await deriveKeySignatures( 235 | this.#password, 236 | salt, 237 | iterCount, 238 | ); 239 | 240 | this.#auth_message += "," + challenge; 241 | this.#state = AuthenticationState.ServerChallenge; 242 | } catch (e) { 243 | this.#state = AuthenticationState.Failed; 244 | throw e; 245 | } 246 | } 247 | 248 | /** 249 | * Composes client-final-message 250 | */ 251 | async composeResponse(): Promise { 252 | assert(this.#state === AuthenticationState.ServerChallenge); 253 | assert(this.#key_signatures); 254 | assert(this.#server_nonce); 255 | 256 | try { 257 | // "biws" is the base-64 encoded form of the gs2-header "n,,". 258 | const responseWithoutProof = `c=biws,r=${this.#server_nonce}`; 259 | 260 | this.#auth_message += "," + responseWithoutProof; 261 | 262 | const proof = encodeBase64( 263 | computeScramProof( 264 | await computeScramSignature( 265 | this.#auth_message, 266 | this.#key_signatures.stored, 267 | ), 268 | this.#key_signatures.client, 269 | ), 270 | ); 271 | const message = `${responseWithoutProof},p=${proof}`; 272 | 273 | this.#state = AuthenticationState.ClientResponse; 274 | return message; 275 | } catch (e) { 276 | this.#state = AuthenticationState.Failed; 277 | throw e; 278 | } 279 | } 280 | 281 | /** 282 | * Processes server-final-message 283 | */ 284 | async receiveResponse(response: string) { 285 | assert(this.#state === AuthenticationState.ClientResponse); 286 | assert(this.#key_signatures); 287 | 288 | try { 289 | const attrs = parseScramAttributes(response); 290 | 291 | if (attrs.e) { 292 | throw new Error(attrs.e ?? Reason.Rejected); 293 | } 294 | 295 | const verifier = encodeBase64( 296 | await computeScramSignature( 297 | this.#auth_message, 298 | this.#key_signatures.server, 299 | ), 300 | ); 301 | if (attrs.v !== verifier) { 302 | throw new Error(Reason.BadVerifier); 303 | } 304 | 305 | this.#state = AuthenticationState.ServerResponse; 306 | } catch (e) { 307 | this.#state = AuthenticationState.Failed; 308 | throw e; 309 | } 310 | } 311 | } 312 | -------------------------------------------------------------------------------- /debug.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Controls debugging behavior. If set to `true`, all debug options are enabled. 3 | * If set to `false`, all debug options are disabled. Can also be an object with 4 | * specific debug options to enable. 5 | * 6 | * {@default false} 7 | */ 8 | export type DebugControls = DebugOptions | boolean; 9 | 10 | type DebugOptions = { 11 | /** Log all queries */ 12 | queries?: boolean; 13 | /** Log all INFO, NOTICE, and WARNING raised database messages */ 14 | notices?: boolean; 15 | /** Log all results */ 16 | results?: boolean; 17 | /** Include the SQL query that caused an error in the PostgresError object */ 18 | queryInError?: boolean; 19 | }; 20 | 21 | export const isDebugOptionEnabled = ( 22 | option: keyof DebugOptions, 23 | options?: DebugControls, 24 | ): boolean => { 25 | if (typeof options === "boolean") { 26 | return options; 27 | } 28 | 29 | return !!options?.[option]; 30 | }; 31 | -------------------------------------------------------------------------------- /deno.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@db/postgres", 3 | "version": "0.19.5", 4 | "license": "MIT", 5 | "exports": "./mod.ts", 6 | "imports": { 7 | "@std/bytes": "jsr:@std/bytes@^1.0.5", 8 | "@std/crypto": "jsr:@std/crypto@^1.0.4", 9 | "@std/encoding": "jsr:@std/encoding@^1.0.9", 10 | "@std/fmt": "jsr:@std/fmt@^1.0.6", 11 | "@std/path": "jsr:@std/path@^1.0.8" 12 | }, 13 | "lock": false 14 | } 15 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | x-database-env: 2 | &database-env 3 | POSTGRES_DB: "postgres" 4 | POSTGRES_PASSWORD: "postgres" 5 | POSTGRES_USER: "postgres" 6 | 7 | x-test-env: 8 | &test-env 9 | WAIT_HOSTS: "postgres_clear:6000,postgres_md5:6001,postgres_scram:6002" 10 | # Wait fifteen seconds after database goes online 11 | # for database metadata initialization 12 | WAIT_AFTER: "15" 13 | 14 | x-test-volumes: 15 | &test-volumes 16 | - /var/run/postgres_clear:/var/run/postgres_clear 17 | - /var/run/postgres_md5:/var/run/postgres_md5 18 | - /var/run/postgres_scram:/var/run/postgres_scram 19 | 20 | services: 21 | postgres_clear: 22 | # Clear authentication was removed after Postgres 9 23 | image: postgres:9 24 | hostname: postgres_clear 25 | environment: 26 | <<: *database-env 27 | volumes: 28 | - ./docker/postgres_clear/data/:/var/lib/postgresql/host/ 29 | - ./docker/postgres_clear/init/:/docker-entrypoint-initdb.d/ 30 | - /var/run/postgres_clear:/var/run/postgresql 31 | ports: 32 | - "6000:6000" 33 | 34 | postgres_md5: 35 | image: postgres:14 36 | hostname: postgres_md5 37 | environment: 38 | <<: *database-env 39 | volumes: 40 | - ./docker/postgres_md5/data/:/var/lib/postgresql/host/ 41 | - ./docker/postgres_md5/init/:/docker-entrypoint-initdb.d/ 42 | - /var/run/postgres_md5:/var/run/postgresql 43 | ports: 44 | - "6001:6001" 45 | 46 | postgres_scram: 47 | image: postgres:14 48 | hostname: postgres_scram 49 | environment: 50 | <<: *database-env 51 | POSTGRES_HOST_AUTH_METHOD: "scram-sha-256" 52 | POSTGRES_INITDB_ARGS: "--auth-host=scram-sha-256" 53 | volumes: 54 | - ./docker/postgres_scram/data/:/var/lib/postgresql/host/ 55 | - ./docker/postgres_scram/init/:/docker-entrypoint-initdb.d/ 56 | - /var/run/postgres_scram:/var/run/postgresql 57 | ports: 58 | - "6002:6002" 59 | 60 | tests: 61 | build: . 62 | # Name the image to be reused in no_check_tests 63 | image: postgres/tests 64 | command: sh -c "/wait && deno test -A --parallel --check" 65 | depends_on: 66 | - postgres_clear 67 | - postgres_md5 68 | - postgres_scram 69 | environment: 70 | <<: *test-env 71 | volumes: *test-volumes 72 | 73 | no_check_tests: 74 | image: postgres/tests 75 | command: sh -c "/wait && deno test -A --parallel --no-check" 76 | depends_on: 77 | - tests 78 | environment: 79 | <<: *test-env 80 | NO_COLOR: "true" 81 | volumes: *test-volumes 82 | 83 | doc_tests: 84 | image: postgres/tests 85 | command: sh -c "/wait && deno test -A --doc client.ts mod.ts pool.ts client/ connection/ query/ utils/" 86 | depends_on: 87 | - postgres_clear 88 | - postgres_md5 89 | - postgres_scram 90 | environment: 91 | <<: *test-env 92 | PGDATABASE: "postgres" 93 | PGPASSWORD: "postgres" 94 | PGUSER: "postgres" 95 | PGHOST: "postgres_md5" 96 | PGPORT: 6001 97 | volumes: *test-volumes 98 | -------------------------------------------------------------------------------- /docker/certs/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | 3 | !.gitignore 4 | !ca.crt 5 | !domains.txt -------------------------------------------------------------------------------- /docker/certs/ca.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDMTCCAhmgAwIBAgIUKLHJN8gpJJ4LwL/cWGMxeekyWCwwDQYJKoZIhvcNAQEL 3 | BQAwJzELMAkGA1UEBhMCVVMxGDAWBgNVBAMMD0V4YW1wbGUtUm9vdC1DQTAgFw0y 4 | MjAxMDcwMzAzNTBaGA8yMTIwMTIxNDAzMDM1MFowJzELMAkGA1UEBhMCVVMxGDAW 5 | BgNVBAMMD0V4YW1wbGUtUm9vdC1DQTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCC 6 | AQoCggEBAMZRF6YG2pN5HQ4F0Xnk0JeApa0GzKAisv0TTnmUHDKaM8WtVk6M48Co 7 | H7avyM4q1Tzfw+3kad2HcEFtZ3LNhztG2zE8lI9P82qNYmnbukYkyAzADpywzOeG 8 | CqbH4ejHhdNEZWP9wUteucJ5TnbC4u07c+bgNQb8crnfiW9Is+JShfe1agU6NKkZ 9 | GkF+/SYzOUS9geP3cj0BrtSboUz62NKl4dU+TMMUjmgWDXuwun5WB7kBm61z8nNq 10 | SAJOd1g5lWrEr+D32q8zN8gP09fT7XDZHXWA8+MdO2UB3VV+SSVo7Yn5QyiUrVvC 11 | An+etIE52K67OZTjrn6gw8lgmiX+PTECAwEAAaNTMFEwHQYDVR0OBBYEFIte+NgJ 12 | uUTwh7ptEzJD3zJXvqtCMB8GA1UdIwQYMBaAFIte+NgJuUTwh7ptEzJD3zJXvqtC 13 | MA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAIEbNu38wBqUHlZY 14 | FQsNLmizA5qH4Bo+0TwDAHxa8twHarhkxPVpz8tA0Zw8CsQ56ow6JkHJblKXKZlS 15 | rwI2ciHUxTnvnBGiVmGgM3pz99OEKGRtHn8RRJrTI42P1a1NOqOAwMLI6cl14eCo 16 | UkHlgxMHtsrC5gZawPs/sfPg5AuuIZy6qjBLaByPBQTO14BPzlEcPzSniZjzPsVz 17 | w5cuVxzBoRxu+jsEzLqQBb24amO2bHshfG9TV1VVyDxaI0E5dGO3cO5BxpriQytn 18 | BMy3sgOVTnaZkVG9Pb2CRSZ7f2FZIgTCGsuj3oeZU1LdhUbnSdll7iLIFqUBohw/ 19 | 0COUBJ8= 20 | -----END CERTIFICATE----- 21 | -------------------------------------------------------------------------------- /docker/certs/domains.txt: -------------------------------------------------------------------------------- 1 | authorityKeyIdentifier=keyid,issuer 2 | basicConstraints=CA:FALSE 3 | keyUsage = digitalSignature, nonRepudiation, keyEncipherment, dataEncipherment 4 | subjectAltName = @alt_names 5 | [alt_names] 6 | DNS.1 = localhost 7 | DNS.2 = postgres_clear 8 | DNS.3 = postgres_md5 9 | DNS.4 = postgres_scram 10 | -------------------------------------------------------------------------------- /docker/generate_tls_keys.sh: -------------------------------------------------------------------------------- 1 | # Set CWD relative to script location 2 | cd "$(dirname "$0")" 3 | 4 | # Generate CA certificate and key 5 | openssl req -x509 -nodes -new -sha256 -days 36135 -newkey rsa:2048 -keyout ./certs/ca.key -out ./certs/ca.pem -subj "/C=US/CN=Example-Root-CA" 6 | openssl x509 -outform pem -in ./certs/ca.pem -out ./certs/ca.crt 7 | 8 | # Generate leaf certificate 9 | openssl req -new -nodes -newkey rsa:2048 -keyout ./certs/server.key -out ./certs/server.csr -subj "/C=US/ST=YourState/L=YourCity/O=Example-Certificates/CN=localhost" 10 | openssl x509 -req -sha256 -days 36135 -in ./certs/server.csr -CA ./certs/ca.pem -CAkey ./certs/ca.key -CAcreateserial -extfile ./certs/domains.txt -out ./certs/server.crt 11 | 12 | chmod 777 certs/server.crt 13 | cp -f certs/server.crt postgres_clear/data/ 14 | cp -f certs/server.crt postgres_md5/data/ 15 | cp -f certs/server.crt postgres_scram/data/ 16 | 17 | chmod 777 certs/server.key 18 | cp -f certs/server.key postgres_clear/data/ 19 | cp -f certs/server.key postgres_md5/data/ 20 | cp -f certs/server.key postgres_scram/data/ 21 | -------------------------------------------------------------------------------- /docker/postgres_clear/data/pg_hba.conf: -------------------------------------------------------------------------------- 1 | hostssl postgres clear 0.0.0.0/0 password 2 | hostnossl postgres clear 0.0.0.0/0 password 3 | hostssl all postgres 0.0.0.0/0 md5 4 | hostnossl all postgres 0.0.0.0/0 md5 5 | local postgres socket md5 6 | 7 | -------------------------------------------------------------------------------- /docker/postgres_clear/data/postgresql.conf: -------------------------------------------------------------------------------- 1 | port = 6000 2 | ssl = on 3 | ssl_cert_file = 'server.crt' 4 | ssl_key_file = 'server.key' 5 | -------------------------------------------------------------------------------- /docker/postgres_clear/data/server.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDnTCCAoWgAwIBAgIUCeSCBCVxR0+kf5GcadXrLln0WdswDQYJKoZIhvcNAQEL 3 | BQAwJzELMAkGA1UEBhMCVVMxGDAWBgNVBAMMD0V4YW1wbGUtUm9vdC1DQTAgFw0y 4 | MjAxMDcwMzAzNTBaGA8yMTIwMTIxNDAzMDM1MFowZzELMAkGA1UEBhMCVVMxEjAQ 5 | BgNVBAgMCVlvdXJTdGF0ZTERMA8GA1UEBwwIWW91ckNpdHkxHTAbBgNVBAoMFEV4 6 | YW1wbGUtQ2VydGlmaWNhdGVzMRIwEAYDVQQDDAlsb2NhbGhvc3QwggEiMA0GCSqG 7 | SIb3DQEBAQUAA4IBDwAwggEKAoIBAQCwRoa0e8Oi6HI1Ixa4DW6S6V44fijWvDr9 8 | 6mJqEoVY8X/ZXW6RGYpcCyXc/ZEAaBnqRcujylpcVgGePTH3hFnNkWfPDUOmKNIt 9 | fRK4jQL6dssv1mmW3s6Li5wS/UGq3CLH5jKGHNHKaIZapq0QgLmlv3dRF8SdwJB/ 10 | B6q5XEFlNK+cAH5fiL2p8CD8AZGYxZ6kU3FDjN8PnQIQVnsj9G21/3ChYd3uC0/c 11 | wDcy9DTAoPZ6ZdZJ6wZkmtpidG+0VNA7esuVzLpcOOfrohemVeNPapFp73BskBPy 12 | kxgfrDHdaecqypZSo2keAWFx7se231QYaY0uXJYXtao6QSKCuha3AgMBAAGjfzB9 13 | MB8GA1UdIwQYMBaAFIte+NgJuUTwh7ptEzJD3zJXvqtCMAkGA1UdEwQCMAAwCwYD 14 | VR0PBAQDAgTwMEIGA1UdEQQ7MDmCCWxvY2FsaG9zdIIOcG9zdGdyZXNfY2xlYXKC 15 | DHBvc3RncmVzX21kNYIOcG9zdGdyZXNfc2NyYW0wDQYJKoZIhvcNAQELBQADggEB 16 | AGaPCbKlh9HXu1W+Q5FreyUgkbKhYV6j3GfNt47CKehVs8Q4qrLAg/k6Pl1Fxaxw 17 | jEorwuLaI7YVEIcJi2m4kb1ipIikCkIPt5K1Vo/GOrLoRfer8QcRQBMhM4kZMhlr 18 | MERl/PHpgllU0PQF/f95sxlFHqWTOiTomEite3XKvurkkAumcAxO2GiuDWK0CkZu 19 | WGsl5MNoVPT2jJ+xcIefw8anTx4IbElYbiWFC0MgnRTNrD+hHvKDKoVzZDqQKj/s 20 | 7CYAv4m9jvv+06nNC5IyUd57hAv/5lt2e4U1bS4kvm0IWtW3tJBx/NSdybrVj5oZ 21 | McVPTeO5pAgwpZY8BFUdCvQ= 22 | -----END CERTIFICATE----- 23 | -------------------------------------------------------------------------------- /docker/postgres_clear/data/server.key: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCwRoa0e8Oi6HI1 3 | Ixa4DW6S6V44fijWvDr96mJqEoVY8X/ZXW6RGYpcCyXc/ZEAaBnqRcujylpcVgGe 4 | PTH3hFnNkWfPDUOmKNItfRK4jQL6dssv1mmW3s6Li5wS/UGq3CLH5jKGHNHKaIZa 5 | pq0QgLmlv3dRF8SdwJB/B6q5XEFlNK+cAH5fiL2p8CD8AZGYxZ6kU3FDjN8PnQIQ 6 | Vnsj9G21/3ChYd3uC0/cwDcy9DTAoPZ6ZdZJ6wZkmtpidG+0VNA7esuVzLpcOOfr 7 | ohemVeNPapFp73BskBPykxgfrDHdaecqypZSo2keAWFx7se231QYaY0uXJYXtao6 8 | QSKCuha3AgMBAAECggEAQgLHIwNN6c2eJyPyuA3foIhfzkwAQxnOBZQmMo6o/PvC 9 | 4sVISHIGDB3ome8iw8I4IjDs53M5j2ZtyLIl6gjYEFEpTLIs6SZUPtCdmBrGSMD/ 10 | qfRjKipZsowfcEUCuFcjdzRPK0XTkja+SWgtWwa5fsZKikWaTXD1K3zVhAB2RM1s 11 | jMo2UY+EcTfrkYA4FDv8KRHunRNyPOMYr/b7axjbh0xzzMCvfUSE42IglRw1tuiE 12 | ogKNY3nzYZvX8hXr3Ccy9PIA6ieehgFdBfEDDTPFI460gPyFU670Q52sHXIhV8lP 13 | eFZg9aJ2Xc27xZluYaGXJj7PDpekOVIIj3sI23/hEQKBgQDkEfXSMvXL1rcoiqlG 14 | iuLrQYGbmzNRkFaOztUhAqCu/sfiZYr82RejhMyMUDT1fCDtjXYnITcD6INYfwRX 15 | 9rab/MSe3BIpRbGynEN29pLQqSloRu5qhXrus3cMixmgXhlBYPIAg+nT/dSRLUJl 16 | IR/Dh8uclCtM5uPCsv9R0ojaQwKBgQDF3MtIGby18WKvySf1uR8tFcZNFUqktpvS 17 | oHPcVI/SUxQkGF5bFZ6NyA3+9+Sfo6Zya46zv5XgMR8FvP1/TMNpIQ5xsbuk/pRc 18 | jx/Hx7QHE/MX/cEZGABjXkHptZhGv7sNdNWL8IcYk1qsTwzaIpbau1KCahkObscp 19 | X9+dAcwsfQKBgH4QU2FRm72FPI5jPrfoUw+YkMxzGAWwk7eyKepqKmkwGUpRuGaU 20 | lNVktS+lsfAzIXxNIg709BTr85X592uryjokmIX6vOslQ9inOT9LgdFmf6XM90HX 21 | 8CB7AIXlaU/UU39o17tjLt9nwZRRgQ6nJYiNygUNfXWvdhuLl0ch6VVDAoGAPLbJ 22 | sfAj1fih/arOFjqd9GmwFcsowm4+Vl1h8AQKtdFEZucLXQu/QWZX1RsgDlRbKNUU 23 | TtfFF6w7Brm9V6iodcPs+Lo/CBwOTnCkodsHxPw8Jep5rEePJu6vbxWICn2e2jw1 24 | ouFFsybUNfdzzCO9ApVkdhw0YBdiCbIfncAFdMkCgYB1CmGeZ7fEl8ByCLkpIAke 25 | DMgO69cB2JDWugqZIzZT5BsxSCXvOm0J4zQuzThY1RvYKRXqg3tjNDmWhYll5tmS 26 | MEcl6hx1RbZUHDsKlKXkdBd1fDCALC0w4iTEg8OVCF4CM50T4+zuSoED9gCCItpK 27 | fCoYn3ScgCEJA3HdUGLy4g== 28 | -----END PRIVATE KEY----- 29 | -------------------------------------------------------------------------------- /docker/postgres_clear/init/initialize_test_server.sh: -------------------------------------------------------------------------------- 1 | cat /var/lib/postgresql/host/postgresql.conf >> /var/lib/postgresql/data/postgresql.conf 2 | cp /var/lib/postgresql/host/pg_hba.conf /var/lib/postgresql/data 3 | cp /var/lib/postgresql/host/server.crt /var/lib/postgresql/data 4 | cp /var/lib/postgresql/host/server.key /var/lib/postgresql/data 5 | chmod 600 /var/lib/postgresql/data/server.crt 6 | chmod 600 /var/lib/postgresql/data/server.key 7 | -------------------------------------------------------------------------------- /docker/postgres_clear/init/initialize_test_server.sql: -------------------------------------------------------------------------------- 1 | CREATE USER CLEAR WITH UNENCRYPTED PASSWORD 'postgres'; 2 | GRANT ALL PRIVILEGES ON DATABASE POSTGRES TO CLEAR; 3 | 4 | CREATE USER SOCKET WITH UNENCRYPTED PASSWORD 'postgres'; 5 | GRANT ALL PRIVILEGES ON DATABASE POSTGRES TO SOCKET; 6 | -------------------------------------------------------------------------------- /docker/postgres_md5/data/pg_hba.conf: -------------------------------------------------------------------------------- 1 | hostssl postgres md5 0.0.0.0/0 md5 2 | hostnossl postgres md5 0.0.0.0/0 md5 3 | hostssl all postgres 0.0.0.0/0 scram-sha-256 4 | hostnossl all postgres 0.0.0.0/0 scram-sha-256 5 | hostssl postgres tls_only 0.0.0.0/0 md5 6 | local postgres socket md5 7 | -------------------------------------------------------------------------------- /docker/postgres_md5/data/postgresql.conf: -------------------------------------------------------------------------------- 1 | port = 6001 2 | ssl = on 3 | ssl_cert_file = 'server.crt' 4 | ssl_key_file = 'server.key' 5 | -------------------------------------------------------------------------------- /docker/postgres_md5/data/server.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDnTCCAoWgAwIBAgIUCeSCBCVxR0+kf5GcadXrLln0WdswDQYJKoZIhvcNAQEL 3 | BQAwJzELMAkGA1UEBhMCVVMxGDAWBgNVBAMMD0V4YW1wbGUtUm9vdC1DQTAgFw0y 4 | MjAxMDcwMzAzNTBaGA8yMTIwMTIxNDAzMDM1MFowZzELMAkGA1UEBhMCVVMxEjAQ 5 | BgNVBAgMCVlvdXJTdGF0ZTERMA8GA1UEBwwIWW91ckNpdHkxHTAbBgNVBAoMFEV4 6 | YW1wbGUtQ2VydGlmaWNhdGVzMRIwEAYDVQQDDAlsb2NhbGhvc3QwggEiMA0GCSqG 7 | SIb3DQEBAQUAA4IBDwAwggEKAoIBAQCwRoa0e8Oi6HI1Ixa4DW6S6V44fijWvDr9 8 | 6mJqEoVY8X/ZXW6RGYpcCyXc/ZEAaBnqRcujylpcVgGePTH3hFnNkWfPDUOmKNIt 9 | fRK4jQL6dssv1mmW3s6Li5wS/UGq3CLH5jKGHNHKaIZapq0QgLmlv3dRF8SdwJB/ 10 | B6q5XEFlNK+cAH5fiL2p8CD8AZGYxZ6kU3FDjN8PnQIQVnsj9G21/3ChYd3uC0/c 11 | wDcy9DTAoPZ6ZdZJ6wZkmtpidG+0VNA7esuVzLpcOOfrohemVeNPapFp73BskBPy 12 | kxgfrDHdaecqypZSo2keAWFx7se231QYaY0uXJYXtao6QSKCuha3AgMBAAGjfzB9 13 | MB8GA1UdIwQYMBaAFIte+NgJuUTwh7ptEzJD3zJXvqtCMAkGA1UdEwQCMAAwCwYD 14 | VR0PBAQDAgTwMEIGA1UdEQQ7MDmCCWxvY2FsaG9zdIIOcG9zdGdyZXNfY2xlYXKC 15 | DHBvc3RncmVzX21kNYIOcG9zdGdyZXNfc2NyYW0wDQYJKoZIhvcNAQELBQADggEB 16 | AGaPCbKlh9HXu1W+Q5FreyUgkbKhYV6j3GfNt47CKehVs8Q4qrLAg/k6Pl1Fxaxw 17 | jEorwuLaI7YVEIcJi2m4kb1ipIikCkIPt5K1Vo/GOrLoRfer8QcRQBMhM4kZMhlr 18 | MERl/PHpgllU0PQF/f95sxlFHqWTOiTomEite3XKvurkkAumcAxO2GiuDWK0CkZu 19 | WGsl5MNoVPT2jJ+xcIefw8anTx4IbElYbiWFC0MgnRTNrD+hHvKDKoVzZDqQKj/s 20 | 7CYAv4m9jvv+06nNC5IyUd57hAv/5lt2e4U1bS4kvm0IWtW3tJBx/NSdybrVj5oZ 21 | McVPTeO5pAgwpZY8BFUdCvQ= 22 | -----END CERTIFICATE----- 23 | -------------------------------------------------------------------------------- /docker/postgres_md5/data/server.key: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCwRoa0e8Oi6HI1 3 | Ixa4DW6S6V44fijWvDr96mJqEoVY8X/ZXW6RGYpcCyXc/ZEAaBnqRcujylpcVgGe 4 | PTH3hFnNkWfPDUOmKNItfRK4jQL6dssv1mmW3s6Li5wS/UGq3CLH5jKGHNHKaIZa 5 | pq0QgLmlv3dRF8SdwJB/B6q5XEFlNK+cAH5fiL2p8CD8AZGYxZ6kU3FDjN8PnQIQ 6 | Vnsj9G21/3ChYd3uC0/cwDcy9DTAoPZ6ZdZJ6wZkmtpidG+0VNA7esuVzLpcOOfr 7 | ohemVeNPapFp73BskBPykxgfrDHdaecqypZSo2keAWFx7se231QYaY0uXJYXtao6 8 | QSKCuha3AgMBAAECggEAQgLHIwNN6c2eJyPyuA3foIhfzkwAQxnOBZQmMo6o/PvC 9 | 4sVISHIGDB3ome8iw8I4IjDs53M5j2ZtyLIl6gjYEFEpTLIs6SZUPtCdmBrGSMD/ 10 | qfRjKipZsowfcEUCuFcjdzRPK0XTkja+SWgtWwa5fsZKikWaTXD1K3zVhAB2RM1s 11 | jMo2UY+EcTfrkYA4FDv8KRHunRNyPOMYr/b7axjbh0xzzMCvfUSE42IglRw1tuiE 12 | ogKNY3nzYZvX8hXr3Ccy9PIA6ieehgFdBfEDDTPFI460gPyFU670Q52sHXIhV8lP 13 | eFZg9aJ2Xc27xZluYaGXJj7PDpekOVIIj3sI23/hEQKBgQDkEfXSMvXL1rcoiqlG 14 | iuLrQYGbmzNRkFaOztUhAqCu/sfiZYr82RejhMyMUDT1fCDtjXYnITcD6INYfwRX 15 | 9rab/MSe3BIpRbGynEN29pLQqSloRu5qhXrus3cMixmgXhlBYPIAg+nT/dSRLUJl 16 | IR/Dh8uclCtM5uPCsv9R0ojaQwKBgQDF3MtIGby18WKvySf1uR8tFcZNFUqktpvS 17 | oHPcVI/SUxQkGF5bFZ6NyA3+9+Sfo6Zya46zv5XgMR8FvP1/TMNpIQ5xsbuk/pRc 18 | jx/Hx7QHE/MX/cEZGABjXkHptZhGv7sNdNWL8IcYk1qsTwzaIpbau1KCahkObscp 19 | X9+dAcwsfQKBgH4QU2FRm72FPI5jPrfoUw+YkMxzGAWwk7eyKepqKmkwGUpRuGaU 20 | lNVktS+lsfAzIXxNIg709BTr85X592uryjokmIX6vOslQ9inOT9LgdFmf6XM90HX 21 | 8CB7AIXlaU/UU39o17tjLt9nwZRRgQ6nJYiNygUNfXWvdhuLl0ch6VVDAoGAPLbJ 22 | sfAj1fih/arOFjqd9GmwFcsowm4+Vl1h8AQKtdFEZucLXQu/QWZX1RsgDlRbKNUU 23 | TtfFF6w7Brm9V6iodcPs+Lo/CBwOTnCkodsHxPw8Jep5rEePJu6vbxWICn2e2jw1 24 | ouFFsybUNfdzzCO9ApVkdhw0YBdiCbIfncAFdMkCgYB1CmGeZ7fEl8ByCLkpIAke 25 | DMgO69cB2JDWugqZIzZT5BsxSCXvOm0J4zQuzThY1RvYKRXqg3tjNDmWhYll5tmS 26 | MEcl6hx1RbZUHDsKlKXkdBd1fDCALC0w4iTEg8OVCF4CM50T4+zuSoED9gCCItpK 27 | fCoYn3ScgCEJA3HdUGLy4g== 28 | -----END PRIVATE KEY----- 29 | -------------------------------------------------------------------------------- /docker/postgres_md5/init/initialize_test_server.sh: -------------------------------------------------------------------------------- 1 | cat /var/lib/postgresql/host/postgresql.conf >> /var/lib/postgresql/data/postgresql.conf 2 | cp /var/lib/postgresql/host/pg_hba.conf /var/lib/postgresql/data 3 | cp /var/lib/postgresql/host/server.crt /var/lib/postgresql/data 4 | cp /var/lib/postgresql/host/server.key /var/lib/postgresql/data 5 | chmod 600 /var/lib/postgresql/data/server.crt 6 | chmod 600 /var/lib/postgresql/data/server.key 7 | -------------------------------------------------------------------------------- /docker/postgres_md5/init/initialize_test_server.sql: -------------------------------------------------------------------------------- 1 | -- Create MD5 users and ensure password is stored as md5 2 | -- They get created as SCRAM-SHA-256 in newer postgres versions 3 | CREATE USER MD5 WITH ENCRYPTED PASSWORD 'postgres'; 4 | GRANT ALL PRIVILEGES ON DATABASE POSTGRES TO MD5; 5 | 6 | UPDATE PG_AUTHID 7 | SET ROLPASSWORD = 'md5'||MD5('postgres'||'md5') 8 | WHERE ROLNAME ILIKE 'MD5'; 9 | 10 | CREATE USER SOCKET WITH ENCRYPTED PASSWORD 'postgres'; 11 | GRANT ALL PRIVILEGES ON DATABASE POSTGRES TO SOCKET; 12 | 13 | UPDATE PG_AUTHID 14 | SET ROLPASSWORD = 'md5'||MD5('postgres'||'socket') 15 | WHERE ROLNAME ILIKE 'SOCKET'; 16 | -------------------------------------------------------------------------------- /docker/postgres_scram/data/pg_hba.conf: -------------------------------------------------------------------------------- 1 | hostssl all postgres 0.0.0.0/0 scram-sha-256 2 | hostnossl all postgres 0.0.0.0/0 scram-sha-256 3 | hostssl postgres scram 0.0.0.0/0 scram-sha-256 4 | hostnossl postgres scram 0.0.0.0/0 scram-sha-256 5 | local postgres socket scram-sha-256 6 | -------------------------------------------------------------------------------- /docker/postgres_scram/data/postgresql.conf: -------------------------------------------------------------------------------- 1 | password_encryption = scram-sha-256 2 | port = 6002 3 | ssl = on 4 | ssl_cert_file = 'server.crt' 5 | ssl_key_file = 'server.key' -------------------------------------------------------------------------------- /docker/postgres_scram/data/server.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDnTCCAoWgAwIBAgIUCeSCBCVxR0+kf5GcadXrLln0WdswDQYJKoZIhvcNAQEL 3 | BQAwJzELMAkGA1UEBhMCVVMxGDAWBgNVBAMMD0V4YW1wbGUtUm9vdC1DQTAgFw0y 4 | MjAxMDcwMzAzNTBaGA8yMTIwMTIxNDAzMDM1MFowZzELMAkGA1UEBhMCVVMxEjAQ 5 | BgNVBAgMCVlvdXJTdGF0ZTERMA8GA1UEBwwIWW91ckNpdHkxHTAbBgNVBAoMFEV4 6 | YW1wbGUtQ2VydGlmaWNhdGVzMRIwEAYDVQQDDAlsb2NhbGhvc3QwggEiMA0GCSqG 7 | SIb3DQEBAQUAA4IBDwAwggEKAoIBAQCwRoa0e8Oi6HI1Ixa4DW6S6V44fijWvDr9 8 | 6mJqEoVY8X/ZXW6RGYpcCyXc/ZEAaBnqRcujylpcVgGePTH3hFnNkWfPDUOmKNIt 9 | fRK4jQL6dssv1mmW3s6Li5wS/UGq3CLH5jKGHNHKaIZapq0QgLmlv3dRF8SdwJB/ 10 | B6q5XEFlNK+cAH5fiL2p8CD8AZGYxZ6kU3FDjN8PnQIQVnsj9G21/3ChYd3uC0/c 11 | wDcy9DTAoPZ6ZdZJ6wZkmtpidG+0VNA7esuVzLpcOOfrohemVeNPapFp73BskBPy 12 | kxgfrDHdaecqypZSo2keAWFx7se231QYaY0uXJYXtao6QSKCuha3AgMBAAGjfzB9 13 | MB8GA1UdIwQYMBaAFIte+NgJuUTwh7ptEzJD3zJXvqtCMAkGA1UdEwQCMAAwCwYD 14 | VR0PBAQDAgTwMEIGA1UdEQQ7MDmCCWxvY2FsaG9zdIIOcG9zdGdyZXNfY2xlYXKC 15 | DHBvc3RncmVzX21kNYIOcG9zdGdyZXNfc2NyYW0wDQYJKoZIhvcNAQELBQADggEB 16 | AGaPCbKlh9HXu1W+Q5FreyUgkbKhYV6j3GfNt47CKehVs8Q4qrLAg/k6Pl1Fxaxw 17 | jEorwuLaI7YVEIcJi2m4kb1ipIikCkIPt5K1Vo/GOrLoRfer8QcRQBMhM4kZMhlr 18 | MERl/PHpgllU0PQF/f95sxlFHqWTOiTomEite3XKvurkkAumcAxO2GiuDWK0CkZu 19 | WGsl5MNoVPT2jJ+xcIefw8anTx4IbElYbiWFC0MgnRTNrD+hHvKDKoVzZDqQKj/s 20 | 7CYAv4m9jvv+06nNC5IyUd57hAv/5lt2e4U1bS4kvm0IWtW3tJBx/NSdybrVj5oZ 21 | McVPTeO5pAgwpZY8BFUdCvQ= 22 | -----END CERTIFICATE----- 23 | -------------------------------------------------------------------------------- /docker/postgres_scram/data/server.key: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCwRoa0e8Oi6HI1 3 | Ixa4DW6S6V44fijWvDr96mJqEoVY8X/ZXW6RGYpcCyXc/ZEAaBnqRcujylpcVgGe 4 | PTH3hFnNkWfPDUOmKNItfRK4jQL6dssv1mmW3s6Li5wS/UGq3CLH5jKGHNHKaIZa 5 | pq0QgLmlv3dRF8SdwJB/B6q5XEFlNK+cAH5fiL2p8CD8AZGYxZ6kU3FDjN8PnQIQ 6 | Vnsj9G21/3ChYd3uC0/cwDcy9DTAoPZ6ZdZJ6wZkmtpidG+0VNA7esuVzLpcOOfr 7 | ohemVeNPapFp73BskBPykxgfrDHdaecqypZSo2keAWFx7se231QYaY0uXJYXtao6 8 | QSKCuha3AgMBAAECggEAQgLHIwNN6c2eJyPyuA3foIhfzkwAQxnOBZQmMo6o/PvC 9 | 4sVISHIGDB3ome8iw8I4IjDs53M5j2ZtyLIl6gjYEFEpTLIs6SZUPtCdmBrGSMD/ 10 | qfRjKipZsowfcEUCuFcjdzRPK0XTkja+SWgtWwa5fsZKikWaTXD1K3zVhAB2RM1s 11 | jMo2UY+EcTfrkYA4FDv8KRHunRNyPOMYr/b7axjbh0xzzMCvfUSE42IglRw1tuiE 12 | ogKNY3nzYZvX8hXr3Ccy9PIA6ieehgFdBfEDDTPFI460gPyFU670Q52sHXIhV8lP 13 | eFZg9aJ2Xc27xZluYaGXJj7PDpekOVIIj3sI23/hEQKBgQDkEfXSMvXL1rcoiqlG 14 | iuLrQYGbmzNRkFaOztUhAqCu/sfiZYr82RejhMyMUDT1fCDtjXYnITcD6INYfwRX 15 | 9rab/MSe3BIpRbGynEN29pLQqSloRu5qhXrus3cMixmgXhlBYPIAg+nT/dSRLUJl 16 | IR/Dh8uclCtM5uPCsv9R0ojaQwKBgQDF3MtIGby18WKvySf1uR8tFcZNFUqktpvS 17 | oHPcVI/SUxQkGF5bFZ6NyA3+9+Sfo6Zya46zv5XgMR8FvP1/TMNpIQ5xsbuk/pRc 18 | jx/Hx7QHE/MX/cEZGABjXkHptZhGv7sNdNWL8IcYk1qsTwzaIpbau1KCahkObscp 19 | X9+dAcwsfQKBgH4QU2FRm72FPI5jPrfoUw+YkMxzGAWwk7eyKepqKmkwGUpRuGaU 20 | lNVktS+lsfAzIXxNIg709BTr85X592uryjokmIX6vOslQ9inOT9LgdFmf6XM90HX 21 | 8CB7AIXlaU/UU39o17tjLt9nwZRRgQ6nJYiNygUNfXWvdhuLl0ch6VVDAoGAPLbJ 22 | sfAj1fih/arOFjqd9GmwFcsowm4+Vl1h8AQKtdFEZucLXQu/QWZX1RsgDlRbKNUU 23 | TtfFF6w7Brm9V6iodcPs+Lo/CBwOTnCkodsHxPw8Jep5rEePJu6vbxWICn2e2jw1 24 | ouFFsybUNfdzzCO9ApVkdhw0YBdiCbIfncAFdMkCgYB1CmGeZ7fEl8ByCLkpIAke 25 | DMgO69cB2JDWugqZIzZT5BsxSCXvOm0J4zQuzThY1RvYKRXqg3tjNDmWhYll5tmS 26 | MEcl6hx1RbZUHDsKlKXkdBd1fDCALC0w4iTEg8OVCF4CM50T4+zuSoED9gCCItpK 27 | fCoYn3ScgCEJA3HdUGLy4g== 28 | -----END PRIVATE KEY----- 29 | -------------------------------------------------------------------------------- /docker/postgres_scram/init/initialize_test_server.sh: -------------------------------------------------------------------------------- 1 | cat /var/lib/postgresql/host/postgresql.conf >> /var/lib/postgresql/data/postgresql.conf 2 | cp /var/lib/postgresql/host/pg_hba.conf /var/lib/postgresql/data 3 | cp /var/lib/postgresql/host/server.crt /var/lib/postgresql/data 4 | cp /var/lib/postgresql/host/server.key /var/lib/postgresql/data 5 | chmod 600 /var/lib/postgresql/data/server.crt 6 | chmod 600 /var/lib/postgresql/data/server.key -------------------------------------------------------------------------------- /docker/postgres_scram/init/initialize_test_server.sql: -------------------------------------------------------------------------------- 1 | CREATE USER SCRAM WITH ENCRYPTED PASSWORD 'postgres'; 2 | GRANT ALL PRIVILEGES ON DATABASE POSTGRES TO SCRAM; 3 | 4 | CREATE USER SOCKET WITH ENCRYPTED PASSWORD 'postgres'; 5 | GRANT ALL PRIVILEGES ON DATABASE POSTGRES TO SOCKET; 6 | -------------------------------------------------------------------------------- /docs/.nojekyll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/denodrivers/postgres/dd7df18fe2ef4da9f1ffa10006763420c89b4b52/docs/.nojekyll -------------------------------------------------------------------------------- /docs/CNAME: -------------------------------------------------------------------------------- 1 | deno-postgres.com -------------------------------------------------------------------------------- /docs/debug-output.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/denodrivers/postgres/dd7df18fe2ef4da9f1ffa10006763420c89b4b52/docs/debug-output.png -------------------------------------------------------------------------------- /docs/deno-postgres.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/denodrivers/postgres/dd7df18fe2ef4da9f1ffa10006763420c89b4b52/docs/deno-postgres.png -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Deno Postgres 6 | 7 | 11 | 15 | 19 | 20 | 21 |
22 | 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /mod.ts: -------------------------------------------------------------------------------- 1 | export { Client } from "./client.ts"; 2 | export { 3 | ConnectionError, 4 | PostgresError, 5 | TransactionError, 6 | } from "./client/error.ts"; 7 | export { Pool } from "./pool.ts"; 8 | export { Oid, type OidType, OidTypes, type OidValue } from "./query/oid.ts"; 9 | export type { 10 | ClientOptions, 11 | ConnectionOptions, 12 | ConnectionString, 13 | Decoders, 14 | DecodeStrategy, 15 | TLSOptions, 16 | } from "./connection/connection_params.ts"; 17 | export type { Session } from "./client.ts"; 18 | export type { Notice } from "./connection/message.ts"; 19 | export { PoolClient, QueryClient } from "./client.ts"; 20 | export type { 21 | CommandType, 22 | QueryArguments, 23 | QueryArrayResult, 24 | QueryObjectOptions, 25 | QueryObjectResult, 26 | QueryOptions, 27 | QueryResult, 28 | ResultType, 29 | RowDescription, 30 | } from "./query/query.ts"; 31 | export { Savepoint, Transaction } from "./query/transaction.ts"; 32 | export type { 33 | IsolationLevel, 34 | TransactionOptions, 35 | } from "./query/transaction.ts"; 36 | -------------------------------------------------------------------------------- /pool.ts: -------------------------------------------------------------------------------- 1 | import { PoolClient } from "./client.ts"; 2 | import { 3 | type ClientConfiguration, 4 | type ClientOptions, 5 | type ConnectionString, 6 | createParams, 7 | } from "./connection/connection_params.ts"; 8 | import { DeferredAccessStack } from "./utils/deferred.ts"; 9 | 10 | /** 11 | * Connection pools are a powerful resource to execute parallel queries and 12 | * save up time in connection initialization. It is highly recommended that all 13 | * applications that require concurrent access use a pool to communicate 14 | * with their PostgreSQL database 15 | * 16 | * ```ts 17 | * import { Pool } from "jsr:@db/postgres"; 18 | * const pool = new Pool({ 19 | * database: Deno.env.get("PGDATABASE"), 20 | * hostname: Deno.env.get("PGHOST"), 21 | * password: Deno.env.get("PGPASSWORD"), 22 | * port: Deno.env.get("PGPORT"), 23 | * user: Deno.env.get("PGUSER"), 24 | * }, 10); // Creates a pool with 10 available connections 25 | * 26 | * const client = await pool.connect(); 27 | * await client.queryArray`SELECT 1`; 28 | * client.release(); 29 | * await pool.end(); 30 | * ``` 31 | * 32 | * You can also opt to not initialize all your connections at once by passing the `lazy` 33 | * option when instantiating your pool, this is useful to reduce startup time. In 34 | * addition to this, the pool won't start the connection unless there isn't any already 35 | * available connections in the pool 36 | * 37 | * ```ts 38 | * import { Pool } from "jsr:@db/postgres"; 39 | * // Creates a pool with 10 max available connections 40 | * // Connection with the database won't be established until the user requires it 41 | * const pool = new Pool({}, 10, true); 42 | * 43 | * // Connection is created here, will be available from now on 44 | * const client_1 = await pool.connect(); 45 | * await client_1.queryArray`SELECT 1`; 46 | * client_1.release(); 47 | * 48 | * // Same connection as before, will be reused instead of starting a new one 49 | * const client_2 = await pool.connect(); 50 | * await client_2.queryArray`SELECT 1`; 51 | * 52 | * // New connection, since previous one is still in use 53 | * // There will be two open connections available from now on 54 | * const client_3 = await pool.connect(); 55 | * client_2.release(); 56 | * client_3.release(); 57 | * await pool.end(); 58 | * ``` 59 | */ 60 | export class Pool { 61 | #available_connections?: DeferredAccessStack; 62 | #connection_params: ClientConfiguration; 63 | #ended = false; 64 | #lazy: boolean; 65 | // TODO 66 | // Initialization should probably have a timeout 67 | #ready: Promise; 68 | #size: number; 69 | 70 | /** 71 | * The number of open connections available for use 72 | * 73 | * Lazily initialized pools won't have any open connections by default 74 | */ 75 | get available(): number { 76 | if (!this.#available_connections) { 77 | return 0; 78 | } 79 | return this.#available_connections.available; 80 | } 81 | 82 | /** 83 | * The number of total connections open in the pool 84 | * 85 | * Both available and in use connections will be counted 86 | */ 87 | get size(): number { 88 | if (!this.#available_connections) { 89 | return 0; 90 | } 91 | return this.#available_connections.size; 92 | } 93 | 94 | /** 95 | * A class that manages connection pooling for PostgreSQL clients 96 | */ 97 | constructor( 98 | connection_params: ClientOptions | ConnectionString | undefined, 99 | size: number, 100 | lazy: boolean = false, 101 | ) { 102 | this.#connection_params = createParams(connection_params); 103 | this.#lazy = lazy; 104 | this.#size = size; 105 | 106 | // This must ALWAYS be called the last 107 | this.#ready = this.#initialize(); 108 | } 109 | 110 | // TODO 111 | // Rename to getClient or similar 112 | // The connect method should initialize the connections instead of doing it 113 | // in the constructor 114 | /** 115 | * This will return a new client from the available connections in 116 | * the pool 117 | * 118 | * In the case of lazy initialized pools, a new connection will be established 119 | * with the database if no other connections are available 120 | * 121 | * ```ts 122 | * import { Pool } from "jsr:@db/postgres"; 123 | * const pool = new Pool({}, 10); 124 | * const client = await pool.connect(); 125 | * await client.queryArray`SELECT * FROM CLIENTS`; 126 | * client.release(); 127 | * await pool.end(); 128 | * ``` 129 | */ 130 | async connect(): Promise { 131 | // Reinitialize pool if it has been terminated 132 | if (this.#ended) { 133 | this.#ready = this.#initialize(); 134 | } 135 | 136 | await this.#ready; 137 | return this.#available_connections!.pop(); 138 | } 139 | 140 | /** 141 | * This will close all open connections and set a terminated status in the pool 142 | * 143 | * ```ts 144 | * import { Pool } from "jsr:@db/postgres"; 145 | * const pool = new Pool({}, 10); 146 | * 147 | * await pool.end(); 148 | * console.assert(pool.available === 0, "There are connections available after ending the pool"); 149 | * try { 150 | * await pool.end(); // An exception will be thrown, pool doesn't have any connections to close 151 | * } catch (e) { 152 | * console.log(e); 153 | * } 154 | * ``` 155 | * 156 | * However, a terminated pool can be reused by using the "connect" method, which 157 | * will reinitialize the connections according to the original configuration of the pool 158 | * 159 | * ```ts 160 | * import { Pool } from "jsr:@db/postgres"; 161 | * const pool = new Pool({}, 10); 162 | * await pool.end(); 163 | * const client = await pool.connect(); 164 | * await client.queryArray`SELECT 1`; // Works! 165 | * client.release(); 166 | * await pool.end(); 167 | * ``` 168 | */ 169 | async end(): Promise { 170 | if (this.#ended) { 171 | throw new Error("Pool connections have already been terminated"); 172 | } 173 | 174 | await this.#ready; 175 | while (this.available > 0) { 176 | const client = await this.#available_connections!.pop(); 177 | await client.end(); 178 | } 179 | 180 | this.#available_connections = undefined; 181 | this.#ended = true; 182 | } 183 | 184 | /** 185 | * Initialization will create all pool clients instances by default 186 | * 187 | * If the pool is lazily initialized, the clients will connect when they 188 | * are requested by the user, otherwise they will all connect on initialization 189 | */ 190 | async #initialize() { 191 | const initialized = this.#lazy ? 0 : this.#size; 192 | const clients = Array.from({ length: this.#size }, async (_e, index) => { 193 | const client: PoolClient = new PoolClient( 194 | this.#connection_params, 195 | () => this.#available_connections!.push(client), 196 | ); 197 | 198 | if (index < initialized) { 199 | await client.connect(); 200 | } 201 | 202 | return client; 203 | }); 204 | 205 | this.#available_connections = new DeferredAccessStack( 206 | await Promise.all(clients), 207 | (client) => client.connect(), 208 | (client) => client.connected, 209 | ); 210 | 211 | this.#ended = false; 212 | } 213 | /** 214 | * This will return the number of initialized clients in the pool 215 | */ 216 | 217 | async initialized(): Promise { 218 | if (!this.#available_connections) { 219 | return 0; 220 | } 221 | 222 | return await this.#available_connections.initialized(); 223 | } 224 | } 225 | -------------------------------------------------------------------------------- /query/array_parser.ts: -------------------------------------------------------------------------------- 1 | // Based of https://github.com/bendrucker/postgres-array 2 | // Copyright (c) Ben Drucker (bendrucker.me). MIT License. 3 | 4 | type AllowedSeparators = "," | ";"; 5 | /** Incorrectly parsed data types default to null */ 6 | type ArrayResult = Array>; 7 | type Transformer = (value: string) => T; 8 | 9 | export type ParseArrayFunction = typeof parseArray; 10 | 11 | /** 12 | * Parse a string into an array of values using the provided transform function. 13 | * 14 | * @param source The string to parse 15 | * @param transform A function to transform each value in the array 16 | * @param separator The separator used to split the string into values 17 | * @returns 18 | */ 19 | export function parseArray( 20 | source: string, 21 | transform: Transformer, 22 | separator: AllowedSeparators = ",", 23 | ): ArrayResult { 24 | return new ArrayParser(source, transform, separator).parse(); 25 | } 26 | 27 | class ArrayParser { 28 | position = 0; 29 | entries: ArrayResult = []; 30 | recorded: string[] = []; 31 | dimension = 0; 32 | 33 | constructor( 34 | public source: string, 35 | public transform: Transformer, 36 | public separator: AllowedSeparators, 37 | ) {} 38 | 39 | isEof(): boolean { 40 | return this.position >= this.source.length; 41 | } 42 | 43 | nextCharacter() { 44 | const character = this.source[this.position++]; 45 | if (character === "\\") { 46 | return { 47 | escaped: true, 48 | value: this.source[this.position++], 49 | }; 50 | } 51 | return { 52 | escaped: false, 53 | value: character, 54 | }; 55 | } 56 | 57 | record(character: string): void { 58 | this.recorded.push(character); 59 | } 60 | 61 | newEntry(includeEmpty = false): void { 62 | let entry; 63 | if (this.recorded.length > 0 || includeEmpty) { 64 | entry = this.recorded.join(""); 65 | if (entry === "NULL" && !includeEmpty) { 66 | entry = null; 67 | } 68 | if (entry !== null) entry = this.transform(entry); 69 | this.entries.push(entry); 70 | this.recorded = []; 71 | } 72 | } 73 | 74 | consumeDimensions(): void { 75 | if (this.source[0] === "[") { 76 | while (!this.isEof()) { 77 | const char = this.nextCharacter(); 78 | if (char.value === "=") break; 79 | } 80 | } 81 | } 82 | 83 | parse(nested = false): ArrayResult { 84 | let character, parser, quote; 85 | this.consumeDimensions(); 86 | while (!this.isEof()) { 87 | character = this.nextCharacter(); 88 | if (character.value === "{" && !quote) { 89 | this.dimension++; 90 | if (this.dimension > 1) { 91 | parser = new ArrayParser( 92 | this.source.substring(this.position - 1), 93 | this.transform, 94 | this.separator, 95 | ); 96 | this.entries.push(parser.parse(true)); 97 | this.position += parser.position - 2; 98 | } 99 | } else if (character.value === "}" && !quote) { 100 | this.dimension--; 101 | if (!this.dimension) { 102 | this.newEntry(); 103 | if (nested) return this.entries; 104 | } 105 | } else if (character.value === '"' && !character.escaped) { 106 | if (quote) this.newEntry(true); 107 | quote = !quote; 108 | } else if (character.value === this.separator && !quote) { 109 | this.newEntry(); 110 | } else { 111 | this.record(character.value); 112 | } 113 | } 114 | if (this.dimension !== 0) { 115 | throw new Error("array dimension not balanced"); 116 | } 117 | return this.entries; 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /query/decode.ts: -------------------------------------------------------------------------------- 1 | import { Oid, type OidType, OidTypes, type OidValue } from "./oid.ts"; 2 | import { bold, yellow } from "@std/fmt/colors"; 3 | import { 4 | decodeBigint, 5 | decodeBigintArray, 6 | decodeBoolean, 7 | decodeBooleanArray, 8 | decodeBox, 9 | decodeBoxArray, 10 | decodeBytea, 11 | decodeByteaArray, 12 | decodeCircle, 13 | decodeCircleArray, 14 | decodeDate, 15 | decodeDateArray, 16 | decodeDatetime, 17 | decodeDatetimeArray, 18 | decodeFloat, 19 | decodeFloatArray, 20 | decodeInt, 21 | decodeIntArray, 22 | decodeJson, 23 | decodeJsonArray, 24 | decodeLine, 25 | decodeLineArray, 26 | decodeLineSegment, 27 | decodeLineSegmentArray, 28 | decodePath, 29 | decodePathArray, 30 | decodePoint, 31 | decodePointArray, 32 | decodePolygon, 33 | decodePolygonArray, 34 | decodeStringArray, 35 | decodeTid, 36 | decodeTidArray, 37 | } from "./decoders.ts"; 38 | import type { ClientControls } from "../connection/connection_params.ts"; 39 | import { parseArray } from "./array_parser.ts"; 40 | 41 | export class Column { 42 | constructor( 43 | public name: string, 44 | public tableOid: number, 45 | public index: number, 46 | public typeOid: number, 47 | public columnLength: number, 48 | public typeModifier: number, 49 | public format: Format, 50 | ) {} 51 | } 52 | 53 | enum Format { 54 | TEXT = 0, 55 | BINARY = 1, 56 | } 57 | 58 | const decoder = new TextDecoder(); 59 | 60 | // TODO 61 | // Decode binary fields 62 | function decodeBinary() { 63 | throw new Error("Decoding binary data is not implemented!"); 64 | } 65 | 66 | function decodeText(value: string, typeOid: number) { 67 | try { 68 | switch (typeOid) { 69 | case Oid.bpchar: 70 | case Oid.char: 71 | case Oid.cidr: 72 | case Oid.float8: 73 | case Oid.inet: 74 | case Oid.macaddr: 75 | case Oid.name: 76 | case Oid.numeric: 77 | case Oid.oid: 78 | case Oid.regclass: 79 | case Oid.regconfig: 80 | case Oid.regdictionary: 81 | case Oid.regnamespace: 82 | case Oid.regoper: 83 | case Oid.regoperator: 84 | case Oid.regproc: 85 | case Oid.regprocedure: 86 | case Oid.regrole: 87 | case Oid.regtype: 88 | case Oid.text: 89 | case Oid.time: 90 | case Oid.timetz: 91 | case Oid.uuid: 92 | case Oid.varchar: 93 | case Oid.void: 94 | return value; 95 | case Oid.bpchar_array: 96 | case Oid.char_array: 97 | case Oid.cidr_array: 98 | case Oid.float8_array: 99 | case Oid.inet_array: 100 | case Oid.macaddr_array: 101 | case Oid.name_array: 102 | case Oid.numeric_array: 103 | case Oid.oid_array: 104 | case Oid.regclass_array: 105 | case Oid.regconfig_array: 106 | case Oid.regdictionary_array: 107 | case Oid.regnamespace_array: 108 | case Oid.regoper_array: 109 | case Oid.regoperator_array: 110 | case Oid.regproc_array: 111 | case Oid.regprocedure_array: 112 | case Oid.regrole_array: 113 | case Oid.regtype_array: 114 | case Oid.text_array: 115 | case Oid.time_array: 116 | case Oid.timetz_array: 117 | case Oid.uuid_array: 118 | case Oid.varchar_array: 119 | return decodeStringArray(value); 120 | case Oid.float4: 121 | return decodeFloat(value); 122 | case Oid.float4_array: 123 | return decodeFloatArray(value); 124 | case Oid.int2: 125 | case Oid.int4: 126 | case Oid.xid: 127 | return decodeInt(value); 128 | case Oid.int2_array: 129 | case Oid.int4_array: 130 | case Oid.xid_array: 131 | return decodeIntArray(value); 132 | case Oid.bool: 133 | return decodeBoolean(value); 134 | case Oid.bool_array: 135 | return decodeBooleanArray(value); 136 | case Oid.box: 137 | return decodeBox(value); 138 | case Oid.box_array: 139 | return decodeBoxArray(value); 140 | case Oid.circle: 141 | return decodeCircle(value); 142 | case Oid.circle_array: 143 | return decodeCircleArray(value); 144 | case Oid.bytea: 145 | return decodeBytea(value); 146 | case Oid.byte_array: 147 | return decodeByteaArray(value); 148 | case Oid.date: 149 | return decodeDate(value); 150 | case Oid.date_array: 151 | return decodeDateArray(value); 152 | case Oid.int8: 153 | return decodeBigint(value); 154 | case Oid.int8_array: 155 | return decodeBigintArray(value); 156 | case Oid.json: 157 | case Oid.jsonb: 158 | return decodeJson(value); 159 | case Oid.json_array: 160 | case Oid.jsonb_array: 161 | return decodeJsonArray(value); 162 | case Oid.line: 163 | return decodeLine(value); 164 | case Oid.line_array: 165 | return decodeLineArray(value); 166 | case Oid.lseg: 167 | return decodeLineSegment(value); 168 | case Oid.lseg_array: 169 | return decodeLineSegmentArray(value); 170 | case Oid.path: 171 | return decodePath(value); 172 | case Oid.path_array: 173 | return decodePathArray(value); 174 | case Oid.point: 175 | return decodePoint(value); 176 | case Oid.point_array: 177 | return decodePointArray(value); 178 | case Oid.polygon: 179 | return decodePolygon(value); 180 | case Oid.polygon_array: 181 | return decodePolygonArray(value); 182 | case Oid.tid: 183 | return decodeTid(value); 184 | case Oid.tid_array: 185 | return decodeTidArray(value); 186 | case Oid.timestamp: 187 | case Oid.timestamptz: 188 | return decodeDatetime(value); 189 | case Oid.timestamp_array: 190 | case Oid.timestamptz_array: 191 | return decodeDatetimeArray(value); 192 | default: 193 | // A separate category for not handled values 194 | // They might or might not be represented correctly as strings, 195 | // returning them to the user as raw strings allows them to parse 196 | // them as they see fit 197 | return value; 198 | } 199 | } catch (e) { 200 | console.error( 201 | bold(yellow(`Error decoding type Oid ${typeOid} value`)) + 202 | (e instanceof Error ? e.message : e) + 203 | "\n" + 204 | bold("Defaulting to null."), 205 | ); 206 | // If an error occurred during decoding, return null 207 | return null; 208 | } 209 | } 210 | 211 | export function decode( 212 | value: Uint8Array, 213 | column: Column, 214 | controls?: ClientControls, 215 | ) { 216 | const strValue = decoder.decode(value); 217 | 218 | // check if there is a custom decoder 219 | if (controls?.decoders) { 220 | const oidType = OidTypes[column.typeOid as OidValue]; 221 | // check if there is a custom decoder by oid (number) or by type name (string) 222 | const decoderFunc = controls.decoders?.[column.typeOid] || 223 | controls.decoders?.[oidType]; 224 | 225 | if (decoderFunc) { 226 | return decoderFunc(strValue, column.typeOid, parseArray); 227 | } // if no custom decoder is found and the oid is for an array type, check if there is 228 | // a decoder for the base type and use that with the array parser 229 | else if (oidType?.includes("_array")) { 230 | const baseOidType = oidType.replace("_array", "") as OidType; 231 | // check if the base type is in the Oid object 232 | if (baseOidType in Oid) { 233 | // check if there is a custom decoder for the base type by oid (number) or by type name (string) 234 | const decoderFunc = controls.decoders?.[Oid[baseOidType]] || 235 | controls.decoders?.[baseOidType]; 236 | if (decoderFunc) { 237 | return parseArray( 238 | strValue, 239 | (value: string) => decoderFunc(value, column.typeOid, parseArray), 240 | ); 241 | } 242 | } 243 | } 244 | } 245 | 246 | // check if the decode strategy is `string` 247 | if (controls?.decodeStrategy === "string") { 248 | return strValue; 249 | } 250 | 251 | // else, default to 'auto' mode, which uses the typeOid to determine the decoding strategy 252 | if (column.format === Format.BINARY) { 253 | return decodeBinary(); 254 | } else if (column.format === Format.TEXT) { 255 | return decodeText(strValue, column.typeOid); 256 | } else { 257 | throw new Error(`Unknown column format: ${column.format}`); 258 | } 259 | } 260 | -------------------------------------------------------------------------------- /query/decoders.ts: -------------------------------------------------------------------------------- 1 | import { parseArray } from "./array_parser.ts"; 2 | import type { 3 | Box, 4 | Circle, 5 | Float8, 6 | Line, 7 | LineSegment, 8 | Path, 9 | Point, 10 | Polygon, 11 | TID, 12 | } from "./types.ts"; 13 | 14 | // Datetime parsing based on: 15 | // https://github.com/bendrucker/postgres-date/blob/master/index.js 16 | // Copyright (c) Ben Drucker (bendrucker.me). MIT License. 17 | const BACKSLASH_BYTE_VALUE = 92; 18 | const BC_RE = /BC$/; 19 | const DATETIME_RE = 20 | /^(\d{1,})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})(\.\d{1,})?/; 21 | const HEX = 16; 22 | const HEX_PREFIX_REGEX = /^\\x/; 23 | const TIMEZONE_RE = /([Z+-])(\d{2})?:?(\d{2})?:?(\d{2})?/; 24 | 25 | export function decodeBigint(value: string): bigint { 26 | return BigInt(value); 27 | } 28 | 29 | export function decodeBigintArray(value: string) { 30 | return parseArray(value, decodeBigint); 31 | } 32 | 33 | export function decodeBoolean(value: string): boolean { 34 | const v = value.toLowerCase(); 35 | return ( 36 | v === "t" || 37 | v === "true" || 38 | v === "y" || 39 | v === "yes" || 40 | v === "on" || 41 | v === "1" 42 | ); 43 | } 44 | 45 | export function decodeBooleanArray(value: string) { 46 | return parseArray(value, decodeBoolean); 47 | } 48 | 49 | export function decodeBox(value: string): Box { 50 | const points = value.match(/\(.*?\)/g) || []; 51 | 52 | if (points.length !== 2) { 53 | throw new Error( 54 | `Invalid Box: "${value}". Box must have only 2 point, ${points.length} given.`, 55 | ); 56 | } 57 | 58 | const [a, b] = points; 59 | 60 | try { 61 | return { 62 | a: decodePoint(a), 63 | b: decodePoint(b), 64 | }; 65 | } catch (e) { 66 | throw new Error( 67 | `Invalid Box: "${value}" : ${(e instanceof Error ? e.message : e)}`, 68 | ); 69 | } 70 | } 71 | 72 | export function decodeBoxArray(value: string) { 73 | return parseArray(value, decodeBox, ";"); 74 | } 75 | 76 | export function decodeBytea(byteaStr: string): Uint8Array { 77 | if (HEX_PREFIX_REGEX.test(byteaStr)) { 78 | return decodeByteaHex(byteaStr); 79 | } else { 80 | return decodeByteaEscape(byteaStr); 81 | } 82 | } 83 | 84 | export function decodeByteaArray(value: string) { 85 | return parseArray(value, decodeBytea); 86 | } 87 | 88 | function decodeByteaEscape(byteaStr: string): Uint8Array { 89 | const bytes = []; 90 | let i = 0; 91 | let k = 0; 92 | while (i < byteaStr.length) { 93 | if (byteaStr[i] !== "\\") { 94 | bytes.push(byteaStr.charCodeAt(i)); 95 | ++i; 96 | } else { 97 | if (/[0-7]{3}/.test(byteaStr.substring(i + 1, i + 4))) { 98 | bytes.push(parseInt(byteaStr.substring(i + 1, i + 4), 8)); 99 | i += 4; 100 | } else { 101 | let backslashes = 1; 102 | while ( 103 | i + backslashes < byteaStr.length && 104 | byteaStr[i + backslashes] === "\\" 105 | ) { 106 | backslashes++; 107 | } 108 | for (k = 0; k < Math.floor(backslashes / 2); ++k) { 109 | bytes.push(BACKSLASH_BYTE_VALUE); 110 | } 111 | i += Math.floor(backslashes / 2) * 2; 112 | } 113 | } 114 | } 115 | return new Uint8Array(bytes); 116 | } 117 | 118 | function decodeByteaHex(byteaStr: string): Uint8Array { 119 | const bytesStr = byteaStr.slice(2); 120 | const bytes = new Uint8Array(bytesStr.length / 2); 121 | for (let i = 0, j = 0; i < bytesStr.length; i += 2, j++) { 122 | bytes[j] = parseInt(bytesStr[i] + bytesStr[i + 1], HEX); 123 | } 124 | return bytes; 125 | } 126 | 127 | export function decodeCircle(value: string): Circle { 128 | const [point, radius] = value 129 | .substring(1, value.length - 1) 130 | .split(/,(?![^(]*\))/) as [string, Float8]; 131 | 132 | if (Number.isNaN(parseFloat(radius))) { 133 | throw new Error( 134 | `Invalid Circle: "${value}". Circle radius "${radius}" must be a valid number.`, 135 | ); 136 | } 137 | 138 | try { 139 | return { 140 | point: decodePoint(point), 141 | radius: radius, 142 | }; 143 | } catch (e) { 144 | throw new Error( 145 | `Invalid Circle: "${value}" : ${(e instanceof Error ? e.message : e)}`, 146 | ); 147 | } 148 | } 149 | 150 | export function decodeCircleArray(value: string) { 151 | return parseArray(value, decodeCircle); 152 | } 153 | 154 | export function decodeDate(dateStr: string): Date | number { 155 | // there are special `infinity` and `-infinity` 156 | // cases representing out-of-range dates 157 | if (dateStr === "infinity") { 158 | return Number(Infinity); 159 | } else if (dateStr === "-infinity") { 160 | return Number(-Infinity); 161 | } 162 | 163 | return new Date(dateStr); 164 | } 165 | 166 | export function decodeDateArray(value: string) { 167 | return parseArray(value, decodeDate); 168 | } 169 | 170 | export function decodeDatetime(dateStr: string): number | Date { 171 | /** 172 | * Postgres uses ISO 8601 style date output by default: 173 | * 1997-12-17 07:37:16-08 174 | */ 175 | 176 | const matches = DATETIME_RE.exec(dateStr); 177 | 178 | if (!matches) { 179 | return decodeDate(dateStr); 180 | } 181 | 182 | const isBC = BC_RE.test(dateStr); 183 | 184 | const year = parseInt(matches[1], 10) * (isBC ? -1 : 1); 185 | // remember JS dates are 0-based 186 | const month = parseInt(matches[2], 10) - 1; 187 | const day = parseInt(matches[3], 10); 188 | const hour = parseInt(matches[4], 10); 189 | const minute = parseInt(matches[5], 10); 190 | const second = parseInt(matches[6], 10); 191 | // ms are written as .007 192 | const msMatch = matches[7]; 193 | const ms = msMatch ? 1000 * parseFloat(msMatch) : 0; 194 | 195 | let date: Date; 196 | 197 | const offset = decodeTimezoneOffset(dateStr); 198 | if (offset === null) { 199 | date = new Date(year, month, day, hour, minute, second, ms); 200 | } else { 201 | // This returns miliseconds from 1 January, 1970, 00:00:00, 202 | // adding decoded timezone offset will construct proper date object. 203 | const utc = Date.UTC(year, month, day, hour, minute, second, ms); 204 | date = new Date(utc + offset); 205 | } 206 | 207 | // use `setUTCFullYear` because if date is from first 208 | // century `Date`'s compatibility for millenium bug 209 | // would set it as 19XX 210 | date.setUTCFullYear(year); 211 | return date; 212 | } 213 | 214 | export function decodeDatetimeArray(value: string) { 215 | return parseArray(value, decodeDatetime); 216 | } 217 | 218 | export function decodeInt(value: string): number { 219 | return parseInt(value, 10); 220 | } 221 | 222 | export function decodeIntArray(value: string) { 223 | return parseArray(value, decodeInt); 224 | } 225 | 226 | export function decodeFloat(value: string): number { 227 | return parseFloat(value); 228 | } 229 | 230 | export function decodeFloatArray(value: string) { 231 | return parseArray(value, decodeFloat); 232 | } 233 | 234 | export function decodeJson(value: string): unknown { 235 | return JSON.parse(value); 236 | } 237 | 238 | export function decodeJsonArray(value: string): unknown[] { 239 | return parseArray(value, JSON.parse); 240 | } 241 | 242 | export function decodeLine(value: string): Line { 243 | const equationConsts = value.substring(1, value.length - 1).split(",") as [ 244 | Float8, 245 | Float8, 246 | Float8, 247 | ]; 248 | 249 | if (equationConsts.length !== 3) { 250 | throw new Error( 251 | `Invalid Line: "${value}". Line in linear equation format must have 3 constants, ${equationConsts.length} given.`, 252 | ); 253 | } 254 | 255 | for (const c of equationConsts) { 256 | if (Number.isNaN(parseFloat(c))) { 257 | throw new Error( 258 | `Invalid Line: "${value}". Line constant "${c}" must be a valid number.`, 259 | ); 260 | } 261 | } 262 | 263 | const [a, b, c] = equationConsts; 264 | 265 | return { 266 | a: a, 267 | b: b, 268 | c: c, 269 | }; 270 | } 271 | 272 | export function decodeLineArray(value: string) { 273 | return parseArray(value, decodeLine); 274 | } 275 | 276 | export function decodeLineSegment(value: string): LineSegment { 277 | const points = value.substring(1, value.length - 1).match(/\(.*?\)/g) || []; 278 | 279 | if (points.length !== 2) { 280 | throw new Error( 281 | `Invalid Line Segment: "${value}". Line segments must have only 2 point, ${points.length} given.`, 282 | ); 283 | } 284 | 285 | const [a, b] = points; 286 | 287 | try { 288 | return { 289 | a: decodePoint(a), 290 | b: decodePoint(b), 291 | }; 292 | } catch (e) { 293 | throw new Error( 294 | `Invalid Line Segment: "${value}" : ${(e instanceof Error 295 | ? e.message 296 | : e)}`, 297 | ); 298 | } 299 | } 300 | 301 | export function decodeLineSegmentArray(value: string) { 302 | return parseArray(value, decodeLineSegment); 303 | } 304 | 305 | export function decodePath(value: string): Path { 306 | // Split on commas that are not inside parantheses 307 | // since encapsulated commas are separators for the point coordinates 308 | const points = value.substring(1, value.length - 1).split(/,(?![^(]*\))/); 309 | 310 | return points.map((point) => { 311 | try { 312 | return decodePoint(point); 313 | } catch (e) { 314 | throw new Error( 315 | `Invalid Path: "${value}" : ${(e instanceof Error ? e.message : e)}`, 316 | ); 317 | } 318 | }); 319 | } 320 | 321 | export function decodePathArray(value: string) { 322 | return parseArray(value, decodePath); 323 | } 324 | 325 | export function decodePoint(value: string): Point { 326 | const coordinates = value 327 | .substring(1, value.length - 1) 328 | .split(",") as Float8[]; 329 | 330 | if (coordinates.length !== 2) { 331 | throw new Error( 332 | `Invalid Point: "${value}". Points must have only 2 coordinates, ${coordinates.length} given.`, 333 | ); 334 | } 335 | 336 | const [x, y] = coordinates; 337 | 338 | if (Number.isNaN(parseFloat(x)) || Number.isNaN(parseFloat(y))) { 339 | throw new Error( 340 | `Invalid Point: "${value}". Coordinate "${ 341 | Number.isNaN(parseFloat(x)) ? x : y 342 | }" must be a valid number.`, 343 | ); 344 | } 345 | 346 | return { 347 | x: x, 348 | y: y, 349 | }; 350 | } 351 | 352 | export function decodePointArray(value: string) { 353 | return parseArray(value, decodePoint); 354 | } 355 | 356 | export function decodePolygon(value: string): Polygon { 357 | try { 358 | return decodePath(value); 359 | } catch (e) { 360 | throw new Error( 361 | `Invalid Polygon: "${value}" : ${(e instanceof Error ? e.message : e)}`, 362 | ); 363 | } 364 | } 365 | 366 | export function decodePolygonArray(value: string) { 367 | return parseArray(value, decodePolygon); 368 | } 369 | 370 | export function decodeStringArray(value: string) { 371 | if (!value) return null; 372 | return parseArray(value, (value) => value); 373 | } 374 | 375 | /** 376 | * Decode numerical timezone offset from provided date string. 377 | * 378 | * Matched these kinds: 379 | * - `Z (UTC)` 380 | * - `-05` 381 | * - `+06:30` 382 | * - `+06:30:10` 383 | * 384 | * Returns offset in miliseconds. 385 | */ 386 | function decodeTimezoneOffset(dateStr: string): null | number { 387 | // get rid of date part as TIMEZONE_RE would match '-MM` part 388 | const timeStr = dateStr.split(" ")[1]; 389 | const matches = TIMEZONE_RE.exec(timeStr); 390 | 391 | if (!matches) { 392 | return null; 393 | } 394 | 395 | const type = matches[1]; 396 | 397 | if (type === "Z") { 398 | // Zulu timezone === UTC === 0 399 | return 0; 400 | } 401 | 402 | // in JS timezone offsets are reversed, ie. timezones 403 | // that are "positive" (+01:00) are represented as negative 404 | // offsets and vice-versa 405 | const sign = type === "-" ? 1 : -1; 406 | 407 | const hours = parseInt(matches[2], 10); 408 | const minutes = parseInt(matches[3] || "0", 10); 409 | const seconds = parseInt(matches[4] || "0", 10); 410 | 411 | const offset = hours * 3600 + minutes * 60 + seconds; 412 | 413 | return sign * offset * 1000; 414 | } 415 | 416 | export function decodeTid(value: string): TID { 417 | const [x, y] = value.substring(1, value.length - 1).split(","); 418 | 419 | return [BigInt(x), BigInt(y)]; 420 | } 421 | 422 | export function decodeTidArray(value: string) { 423 | return parseArray(value, decodeTid); 424 | } 425 | -------------------------------------------------------------------------------- /query/encode.ts: -------------------------------------------------------------------------------- 1 | function pad(number: number, digits: number): string { 2 | let padded = "" + number; 3 | while (padded.length < digits) { 4 | padded = "0" + padded; 5 | } 6 | return padded; 7 | } 8 | 9 | function encodeDate(date: Date): string { 10 | // Construct ISO date 11 | const year = pad(date.getFullYear(), 4); 12 | const month = pad(date.getMonth() + 1, 2); 13 | const day = pad(date.getDate(), 2); 14 | const hour = pad(date.getHours(), 2); 15 | const min = pad(date.getMinutes(), 2); 16 | const sec = pad(date.getSeconds(), 2); 17 | const ms = pad(date.getMilliseconds(), 3); 18 | 19 | const encodedDate = `${year}-${month}-${day}T${hour}:${min}:${sec}.${ms}`; 20 | 21 | // Construct timezone info 22 | // 23 | // Date.prototype.getTimezoneOffset(); 24 | // 25 | // From MDN: 26 | // > The time-zone offset is the difference, in minutes, from local time to UTC. 27 | // > Note that this means that the offset is positive if the local timezone is 28 | // > behind UTC and negative if it is ahead. For example, for time zone UTC+10:00 29 | // > (Australian Eastern Standard Time, Vladivostok Time, Chamorro Standard Time), 30 | // > -600 will be returned. 31 | const offset = date.getTimezoneOffset(); 32 | const tzSign = offset > 0 ? "-" : "+"; 33 | const absOffset = Math.abs(offset); 34 | const tzHours = pad(Math.floor(absOffset / 60), 2); 35 | const tzMinutes = pad(Math.floor(absOffset % 60), 2); 36 | 37 | const encodedTz = `${tzSign}${tzHours}:${tzMinutes}`; 38 | 39 | return encodedDate + encodedTz; 40 | } 41 | 42 | function escapeArrayElement(value: unknown): string { 43 | // deno-lint-ignore no-explicit-any 44 | const strValue = (value as any).toString(); 45 | const escapedValue = strValue.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); 46 | 47 | return `"${escapedValue}"`; 48 | } 49 | 50 | function encodeArray(array: Array): string { 51 | let encodedArray = "{"; 52 | 53 | for (let index = 0; index < array.length; index++) { 54 | if (index > 0) { 55 | encodedArray += ","; 56 | } 57 | 58 | const element = array[index]; 59 | if (element === null || typeof element === "undefined") { 60 | encodedArray += "NULL"; 61 | } else if (Array.isArray(element)) { 62 | encodedArray += encodeArray(element); 63 | } else if (element instanceof Uint8Array) { 64 | encodedArray += encodeBytes(element); 65 | } else { 66 | const encodedElement = encodeArgument(element); 67 | encodedArray += escapeArrayElement(encodedElement as string); 68 | } 69 | } 70 | 71 | encodedArray += "}"; 72 | return encodedArray; 73 | } 74 | 75 | function encodeBytes(value: Uint8Array): string { 76 | const hex = Array.from(value) 77 | .map((val) => (val < 0x10 ? `0${val.toString(16)}` : val.toString(16))) 78 | .join(""); 79 | return `\\x${hex}`; 80 | } 81 | 82 | /** 83 | * Types of a query arguments data encoded for execution 84 | */ 85 | export type EncodedArg = null | string | Uint8Array; 86 | 87 | /** 88 | * Encode (serialize) a value that can be used in a query execution. 89 | */ 90 | export function encodeArgument(value: unknown): EncodedArg { 91 | if (value === null || typeof value === "undefined") { 92 | return null; 93 | } 94 | if (value instanceof Uint8Array) { 95 | return encodeBytes(value); 96 | } 97 | if (value instanceof Date) { 98 | return encodeDate(value); 99 | } 100 | if (value instanceof Array) { 101 | return encodeArray(value); 102 | } 103 | if (value instanceof Object) { 104 | return JSON.stringify(value); 105 | } 106 | return String(value); 107 | } 108 | -------------------------------------------------------------------------------- /query/oid.ts: -------------------------------------------------------------------------------- 1 | /** A Postgres Object identifiers (OIDs) type name. */ 2 | export type OidType = keyof typeof Oid; 3 | /** A Postgres Object identifiers (OIDs) numeric value. */ 4 | export type OidValue = (typeof Oid)[OidType]; 5 | 6 | /** 7 | * A map of OidType to OidValue. 8 | */ 9 | export const Oid = { 10 | bool: 16, 11 | bytea: 17, 12 | char: 18, 13 | name: 19, 14 | int8: 20, 15 | int2: 21, 16 | _int2vector_0: 22, 17 | int4: 23, 18 | regproc: 24, 19 | text: 25, 20 | oid: 26, 21 | tid: 27, 22 | xid: 28, 23 | _cid_0: 29, 24 | _oidvector_0: 30, 25 | _pg_ddl_command: 32, 26 | _pg_type: 71, 27 | _pg_attribute: 75, 28 | _pg_proc: 81, 29 | _pg_class: 83, 30 | json: 114, 31 | _xml_0: 142, 32 | _xml_1: 143, 33 | _pg_node_tree: 194, 34 | json_array: 199, 35 | _smgr: 210, 36 | _index_am_handler: 325, 37 | point: 600, 38 | lseg: 601, 39 | path: 602, 40 | box: 603, 41 | polygon: 604, 42 | line: 628, 43 | line_array: 629, 44 | cidr: 650, 45 | cidr_array: 651, 46 | float4: 700, 47 | float8: 701, 48 | _abstime_0: 702, 49 | _reltime_0: 703, 50 | _tinterval_0: 704, 51 | _unknown: 705, 52 | circle: 718, 53 | circle_array: 719, 54 | _money_0: 790, 55 | _money_1: 791, 56 | macaddr: 829, 57 | inet: 869, 58 | bool_array: 1000, 59 | byte_array: 1001, 60 | char_array: 1002, 61 | name_array: 1003, 62 | int2_array: 1005, 63 | _int2vector_1: 1006, 64 | int4_array: 1007, 65 | regproc_array: 1008, 66 | text_array: 1009, 67 | tid_array: 1010, 68 | xid_array: 1011, 69 | _cid_1: 1012, 70 | _oidvector_1: 1013, 71 | bpchar_array: 1014, 72 | varchar_array: 1015, 73 | int8_array: 1016, 74 | point_array: 1017, 75 | lseg_array: 1018, 76 | path_array: 1019, 77 | box_array: 1020, 78 | float4_array: 1021, 79 | float8_array: 1022, 80 | _abstime_1: 1023, 81 | _reltime_1: 1024, 82 | _tinterval_1: 1025, 83 | polygon_array: 1027, 84 | oid_array: 1028, 85 | _aclitem_0: 1033, 86 | _aclitem_1: 1034, 87 | macaddr_array: 1040, 88 | inet_array: 1041, 89 | bpchar: 1042, 90 | varchar: 1043, 91 | date: 1082, 92 | time: 1083, 93 | timestamp: 1114, 94 | timestamp_array: 1115, 95 | date_array: 1182, 96 | time_array: 1183, 97 | timestamptz: 1184, 98 | timestamptz_array: 1185, 99 | _interval_0: 1186, 100 | _interval_1: 1187, 101 | numeric_array: 1231, 102 | _pg_database: 1248, 103 | _cstring_0: 1263, 104 | timetz: 1266, 105 | timetz_array: 1270, 106 | _bit_0: 1560, 107 | _bit_1: 1561, 108 | _varbit_0: 1562, 109 | _varbit_1: 1563, 110 | numeric: 1700, 111 | _refcursor_0: 1790, 112 | _refcursor_1: 2201, 113 | regprocedure: 2202, 114 | regoper: 2203, 115 | regoperator: 2204, 116 | regclass: 2205, 117 | regtype: 2206, 118 | regprocedure_array: 2207, 119 | regoper_array: 2208, 120 | regoperator_array: 2209, 121 | regclass_array: 2210, 122 | regtype_array: 2211, 123 | _record_0: 2249, 124 | _cstring_1: 2275, 125 | _any: 2276, 126 | _anyarray: 2277, 127 | void: 2278, 128 | _trigger: 2279, 129 | _language_handler: 2280, 130 | _internal: 2281, 131 | _opaque: 2282, 132 | _anyelement: 2283, 133 | _record_1: 2287, 134 | _anynonarray: 2776, 135 | _pg_authid: 2842, 136 | _pg_auth_members: 2843, 137 | _txid_snapshot_0: 2949, 138 | uuid: 2950, 139 | uuid_array: 2951, 140 | _txid_snapshot_1: 2970, 141 | _fdw_handler: 3115, 142 | _pg_lsn_0: 3220, 143 | _pg_lsn_1: 3221, 144 | _tsm_handler: 3310, 145 | _anyenum: 3500, 146 | _tsvector_0: 3614, 147 | _tsquery_0: 3615, 148 | _gtsvector_0: 3642, 149 | _tsvector_1: 3643, 150 | _gtsvector_1: 3644, 151 | _tsquery_1: 3645, 152 | regconfig: 3734, 153 | regconfig_array: 3735, 154 | regdictionary: 3769, 155 | regdictionary_array: 3770, 156 | jsonb: 3802, 157 | jsonb_array: 3807, 158 | _anyrange: 3831, 159 | _event_trigger: 3838, 160 | _int4range_0: 3904, 161 | _int4range_1: 3905, 162 | _numrange_0: 3906, 163 | _numrange_1: 3907, 164 | _tsrange_0: 3908, 165 | _tsrange_1: 3909, 166 | _tstzrange_0: 3910, 167 | _tstzrange_1: 3911, 168 | _daterange_0: 3912, 169 | _daterange_1: 3913, 170 | _int8range_0: 3926, 171 | _int8range_1: 3927, 172 | _pg_shseclabel: 4066, 173 | regnamespace: 4089, 174 | regnamespace_array: 4090, 175 | regrole: 4096, 176 | regrole_array: 4097, 177 | } as const; 178 | 179 | /** 180 | * A map of OidValue to OidType. Used to decode values and avoid search iteration. 181 | */ 182 | export const OidTypes: { 183 | [key in OidValue]: OidType; 184 | } = { 185 | 16: "bool", 186 | 17: "bytea", 187 | 18: "char", 188 | 19: "name", 189 | 20: "int8", 190 | 21: "int2", 191 | 22: "_int2vector_0", 192 | 23: "int4", 193 | 24: "regproc", 194 | 25: "text", 195 | 26: "oid", 196 | 27: "tid", 197 | 28: "xid", 198 | 29: "_cid_0", 199 | 30: "_oidvector_0", 200 | 32: "_pg_ddl_command", 201 | 71: "_pg_type", 202 | 75: "_pg_attribute", 203 | 81: "_pg_proc", 204 | 83: "_pg_class", 205 | 114: "json", 206 | 142: "_xml_0", 207 | 143: "_xml_1", 208 | 194: "_pg_node_tree", 209 | 199: "json_array", 210 | 210: "_smgr", 211 | 325: "_index_am_handler", 212 | 600: "point", 213 | 601: "lseg", 214 | 602: "path", 215 | 603: "box", 216 | 604: "polygon", 217 | 628: "line", 218 | 629: "line_array", 219 | 650: "cidr", 220 | 651: "cidr_array", 221 | 700: "float4", 222 | 701: "float8", 223 | 702: "_abstime_0", 224 | 703: "_reltime_0", 225 | 704: "_tinterval_0", 226 | 705: "_unknown", 227 | 718: "circle", 228 | 719: "circle_array", 229 | 790: "_money_0", 230 | 791: "_money_1", 231 | 829: "macaddr", 232 | 869: "inet", 233 | 1000: "bool_array", 234 | 1001: "byte_array", 235 | 1002: "char_array", 236 | 1003: "name_array", 237 | 1005: "int2_array", 238 | 1006: "_int2vector_1", 239 | 1007: "int4_array", 240 | 1008: "regproc_array", 241 | 1009: "text_array", 242 | 1010: "tid_array", 243 | 1011: "xid_array", 244 | 1012: "_cid_1", 245 | 1013: "_oidvector_1", 246 | 1014: "bpchar_array", 247 | 1015: "varchar_array", 248 | 1016: "int8_array", 249 | 1017: "point_array", 250 | 1018: "lseg_array", 251 | 1019: "path_array", 252 | 1020: "box_array", 253 | 1021: "float4_array", 254 | 1022: "float8_array", 255 | 1023: "_abstime_1", 256 | 1024: "_reltime_1", 257 | 1025: "_tinterval_1", 258 | 1027: "polygon_array", 259 | 1028: "oid_array", 260 | 1033: "_aclitem_0", 261 | 1034: "_aclitem_1", 262 | 1040: "macaddr_array", 263 | 1041: "inet_array", 264 | 1042: "bpchar", 265 | 1043: "varchar", 266 | 1082: "date", 267 | 1083: "time", 268 | 1114: "timestamp", 269 | 1115: "timestamp_array", 270 | 1182: "date_array", 271 | 1183: "time_array", 272 | 1184: "timestamptz", 273 | 1185: "timestamptz_array", 274 | 1186: "_interval_0", 275 | 1187: "_interval_1", 276 | 1231: "numeric_array", 277 | 1248: "_pg_database", 278 | 1263: "_cstring_0", 279 | 1266: "timetz", 280 | 1270: "timetz_array", 281 | 1560: "_bit_0", 282 | 1561: "_bit_1", 283 | 1562: "_varbit_0", 284 | 1563: "_varbit_1", 285 | 1700: "numeric", 286 | 1790: "_refcursor_0", 287 | 2201: "_refcursor_1", 288 | 2202: "regprocedure", 289 | 2203: "regoper", 290 | 2204: "regoperator", 291 | 2205: "regclass", 292 | 2206: "regtype", 293 | 2207: "regprocedure_array", 294 | 2208: "regoper_array", 295 | 2209: "regoperator_array", 296 | 2210: "regclass_array", 297 | 2211: "regtype_array", 298 | 2249: "_record_0", 299 | 2275: "_cstring_1", 300 | 2276: "_any", 301 | 2277: "_anyarray", 302 | 2278: "void", 303 | 2279: "_trigger", 304 | 2280: "_language_handler", 305 | 2281: "_internal", 306 | 2282: "_opaque", 307 | 2283: "_anyelement", 308 | 2287: "_record_1", 309 | 2776: "_anynonarray", 310 | 2842: "_pg_authid", 311 | 2843: "_pg_auth_members", 312 | 2949: "_txid_snapshot_0", 313 | 2950: "uuid", 314 | 2951: "uuid_array", 315 | 2970: "_txid_snapshot_1", 316 | 3115: "_fdw_handler", 317 | 3220: "_pg_lsn_0", 318 | 3221: "_pg_lsn_1", 319 | 3310: "_tsm_handler", 320 | 3500: "_anyenum", 321 | 3614: "_tsvector_0", 322 | 3615: "_tsquery_0", 323 | 3642: "_gtsvector_0", 324 | 3643: "_tsvector_1", 325 | 3644: "_gtsvector_1", 326 | 3645: "_tsquery_1", 327 | 3734: "regconfig", 328 | 3735: "regconfig_array", 329 | 3769: "regdictionary", 330 | 3770: "regdictionary_array", 331 | 3802: "jsonb", 332 | 3807: "jsonb_array", 333 | 3831: "_anyrange", 334 | 3838: "_event_trigger", 335 | 3904: "_int4range_0", 336 | 3905: "_int4range_1", 337 | 3906: "_numrange_0", 338 | 3907: "_numrange_1", 339 | 3908: "_tsrange_0", 340 | 3909: "_tsrange_1", 341 | 3910: "_tstzrange_0", 342 | 3911: "_tstzrange_1", 343 | 3912: "_daterange_0", 344 | 3913: "_daterange_1", 345 | 3926: "_int8range_0", 346 | 3927: "_int8range_1", 347 | 4066: "_pg_shseclabel", 348 | 4089: "regnamespace", 349 | 4090: "regnamespace_array", 350 | 4096: "regrole", 351 | 4097: "regrole_array", 352 | } as const; 353 | -------------------------------------------------------------------------------- /query/query.ts: -------------------------------------------------------------------------------- 1 | import { encodeArgument, type EncodedArg } from "./encode.ts"; 2 | import { type Column, decode } from "./decode.ts"; 3 | import type { Notice } from "../connection/message.ts"; 4 | import type { ClientControls } from "../connection/connection_params.ts"; 5 | 6 | // TODO 7 | // Limit the type of parameters that can be passed 8 | // to a query 9 | /** 10 | * https://www.postgresql.org/docs/14/sql-prepare.html 11 | * 12 | * This arguments will be appended to the prepared statement passed 13 | * as query 14 | * 15 | * They will take the position according to the order in which they were provided 16 | * 17 | * ```ts 18 | * import { Client } from "jsr:@db/postgres"; 19 | * const my_client = new Client(); 20 | * 21 | * await my_client.queryArray("SELECT ID, NAME FROM CLIENTS WHERE NAME = $1", [ 22 | * "John", // $1 23 | * ]); 24 | * 25 | * await my_client.end(); 26 | * ``` 27 | */ 28 | 29 | /** Types of arguments passed to a query */ 30 | export type QueryArguments = unknown[] | Record; 31 | 32 | const commandTagRegexp = /^([A-Za-z]+)(?: (\d+))?(?: (\d+))?/; 33 | 34 | /** Type of query to be executed */ 35 | export type CommandType = 36 | | "INSERT" 37 | | "DELETE" 38 | | "UPDATE" 39 | | "SELECT" 40 | | "MOVE" 41 | | "FETCH" 42 | | "COPY" 43 | | "CREATE"; 44 | 45 | /** Type of a query result */ 46 | export enum ResultType { 47 | ARRAY, 48 | OBJECT, 49 | } 50 | 51 | /** Class to describe a row */ 52 | export class RowDescription { 53 | /** Create a new row description */ 54 | constructor(public columnCount: number, public columns: Column[]) {} 55 | } 56 | 57 | /** 58 | * This function transforms template string arguments into a query 59 | * 60 | * ```ts 61 | * ["SELECT NAME FROM TABLE WHERE ID = ", " AND DATE < "] 62 | * // "SELECT NAME FROM TABLE WHERE ID = $1 AND DATE < $2" 63 | * ``` 64 | */ 65 | export function templateStringToQuery( 66 | template: TemplateStringsArray, 67 | args: unknown[], 68 | result_type: T, 69 | ): Query { 70 | const text = template.reduce((curr, next, index) => { 71 | return `${curr}$${index}${next}`; 72 | }); 73 | 74 | return new Query(text, result_type, args); 75 | } 76 | 77 | function objectQueryToQueryArgs( 78 | query: string, 79 | args: Record, 80 | ): [string, unknown[]] { 81 | args = normalizeObjectQueryArgs(args); 82 | 83 | let counter = 0; 84 | const clean_args: unknown[] = []; 85 | const clean_query = query.replaceAll(/(?<=\$)\w+/g, (match) => { 86 | match = match.toLowerCase(); 87 | if (match in args) { 88 | clean_args.push(args[match]); 89 | } else { 90 | throw new Error( 91 | `No value was provided for the query argument "${match}"`, 92 | ); 93 | } 94 | 95 | return String(++counter); 96 | }); 97 | 98 | return [clean_query, clean_args]; 99 | } 100 | 101 | /** This function lowercases all the keys of the object passed to it and checks for collission names */ 102 | function normalizeObjectQueryArgs( 103 | args: Record, 104 | ): Record { 105 | const normalized_args = Object.fromEntries( 106 | Object.entries(args).map(([key, value]) => [key.toLowerCase(), value]), 107 | ); 108 | 109 | if (Object.keys(normalized_args).length !== Object.keys(args).length) { 110 | throw new Error( 111 | "The arguments provided for the query must be unique (insensitive)", 112 | ); 113 | } 114 | 115 | return normalized_args; 116 | } 117 | 118 | /** Types of options */ 119 | export interface QueryOptions { 120 | /** The arguments to be passed to the query */ 121 | args?: QueryArguments; 122 | /** A custom function to override the encoding logic of the arguments passed to the query */ 123 | encoder?: (arg: unknown) => EncodedArg; 124 | /**The name of the query statement */ 125 | name?: string; 126 | // TODO 127 | // Rename to query 128 | /** The query statement to be executed */ 129 | text: string; 130 | } 131 | 132 | /** Options to control the behavior of a Query instance */ 133 | export interface QueryObjectOptions extends QueryOptions { 134 | // TODO 135 | // Support multiple case options 136 | /** 137 | * Enabling camel case will transform any snake case field names coming from the database into camel case ones 138 | * 139 | * Ex: `SELECT 1 AS my_field` will return `{ myField: 1 }` 140 | * 141 | * This won't have any effect if you explicitly set the field names with the `fields` parameter 142 | */ 143 | camelCase?: boolean; 144 | /** 145 | * This parameter supersedes query column names coming from the databases in the order they were provided. 146 | * Fields must be unique and be in the range of (a-zA-Z0-9_), otherwise the query will throw before execution. 147 | * A field can not start with a number, just like JavaScript variables 148 | * 149 | * This setting overrides the camel case option 150 | * 151 | * Ex: `SELECT 'A', 'B' AS my_field` with fields `["field_1", "field_2"]` will return `{ field_1: "A", field_2: "B" }` 152 | */ 153 | fields?: string[]; 154 | } 155 | 156 | /** 157 | * This class is used to handle the result of a query 158 | */ 159 | export abstract class QueryResult { 160 | /** 161 | * Type of query executed for this result 162 | */ 163 | public command!: CommandType; 164 | /** 165 | * The amount of rows affected by the query 166 | */ 167 | // TODO change to affectedRows 168 | public rowCount?: number; 169 | /** 170 | * This variable will be set after the class initialization, however it's required to be set 171 | * in order to handle result rows coming in 172 | */ 173 | #row_description?: RowDescription; 174 | /** 175 | * The warnings of the result 176 | */ 177 | public warnings: Notice[] = []; 178 | 179 | /** 180 | * The row description of the result 181 | */ 182 | get rowDescription(): RowDescription | undefined { 183 | return this.#row_description; 184 | } 185 | 186 | set rowDescription(row_description: RowDescription | undefined) { 187 | // Prevent #row_description from being changed once set 188 | if (row_description && !this.#row_description) { 189 | this.#row_description = row_description; 190 | } 191 | } 192 | 193 | /** 194 | * Create a query result instance for the query passed 195 | */ 196 | constructor(public query: Query) {} 197 | 198 | /** 199 | * This function is required to parse each column 200 | * of the results 201 | */ 202 | loadColumnDescriptions(description: RowDescription) { 203 | this.rowDescription = description; 204 | } 205 | 206 | /** 207 | * Handles the command complete message 208 | */ 209 | handleCommandComplete(commandTag: string): void { 210 | const match = commandTagRegexp.exec(commandTag); 211 | if (match) { 212 | this.command = match[1] as CommandType; 213 | if (match[3]) { 214 | // COMMAND OID ROWS 215 | this.rowCount = parseInt(match[3], 10); 216 | } else { 217 | // COMMAND ROWS 218 | this.rowCount = parseInt(match[2], 10); 219 | } 220 | } 221 | } 222 | 223 | /** 224 | * Add a row to the result based on metadata provided by `rowDescription` 225 | * This implementation depends on row description not being modified after initialization 226 | * 227 | * This function can throw on validation, so any errors must be handled in the message loop accordingly 228 | */ 229 | abstract insertRow(_row: Uint8Array[]): void; 230 | } 231 | 232 | /** 233 | * This class is used to handle the result of a query that returns an array 234 | */ 235 | export class QueryArrayResult< 236 | T extends Array = Array, 237 | > extends QueryResult { 238 | /** 239 | * The result rows 240 | */ 241 | public rows: T[] = []; 242 | 243 | /** 244 | * Insert a row into the result 245 | */ 246 | insertRow(row_data: Uint8Array[], controls?: ClientControls) { 247 | if (!this.rowDescription) { 248 | throw new Error( 249 | "The row descriptions required to parse the result data weren't initialized", 250 | ); 251 | } 252 | 253 | // Row description won't be modified after initialization 254 | const row = row_data.map((raw_value, index) => { 255 | const column = this.rowDescription!.columns[index]; 256 | 257 | if (raw_value === null) { 258 | return null; 259 | } 260 | return decode(raw_value, column, controls); 261 | }) as T; 262 | 263 | this.rows.push(row); 264 | } 265 | } 266 | 267 | function findDuplicatesInArray(array: string[]): string[] { 268 | return array.reduce((duplicates, item, index) => { 269 | const is_duplicate = array.indexOf(item) !== index; 270 | if (is_duplicate && !duplicates.includes(item)) { 271 | duplicates.push(item); 272 | } 273 | 274 | return duplicates; 275 | }, [] as string[]); 276 | } 277 | 278 | function snakecaseToCamelcase(input: string) { 279 | return input.split("_").reduce((res, word, i) => { 280 | if (i !== 0) { 281 | word = word[0].toUpperCase() + word.slice(1); 282 | } 283 | 284 | res += word; 285 | return res; 286 | }, ""); 287 | } 288 | 289 | /** 290 | * This class is used to handle the result of a query that returns an object 291 | */ 292 | export class QueryObjectResult< 293 | T = Record, 294 | > extends QueryResult { 295 | /** 296 | * The column names will be undefined on the first run of insertRow, since 297 | */ 298 | public columns?: string[]; 299 | /** 300 | * The rows of the result 301 | */ 302 | public rows: T[] = []; 303 | 304 | /** 305 | * Insert a row into the result 306 | */ 307 | insertRow(row_data: Uint8Array[], controls?: ClientControls) { 308 | if (!this.rowDescription) { 309 | throw new Error( 310 | "The row description required to parse the result data wasn't initialized", 311 | ); 312 | } 313 | 314 | // This will only run on the first iteration after row descriptions have been set 315 | if (!this.columns) { 316 | if (this.query.fields) { 317 | if (this.rowDescription.columns.length !== this.query.fields.length) { 318 | throw new RangeError( 319 | "The fields provided for the query don't match the ones returned as a result " + 320 | `(${this.rowDescription.columns.length} expected, ${this.query.fields.length} received)`, 321 | ); 322 | } 323 | 324 | this.columns = this.query.fields; 325 | } else { 326 | let column_names: string[]; 327 | if (this.query.camelCase) { 328 | column_names = this.rowDescription.columns.map((column) => 329 | snakecaseToCamelcase(column.name) 330 | ); 331 | } else { 332 | column_names = this.rowDescription.columns.map( 333 | (column) => column.name, 334 | ); 335 | } 336 | 337 | // Check field names returned by the database are not duplicated 338 | const duplicates = findDuplicatesInArray(column_names); 339 | if (duplicates.length) { 340 | throw new Error( 341 | `Field names ${ 342 | duplicates 343 | .map((str) => `"${str}"`) 344 | .join(", ") 345 | } are duplicated in the result of the query`, 346 | ); 347 | } 348 | 349 | this.columns = column_names; 350 | } 351 | } 352 | 353 | // It's safe to assert columns as defined from now on 354 | const columns = this.columns!; 355 | 356 | if (columns.length !== row_data.length) { 357 | throw new RangeError( 358 | "The result fields returned by the database don't match the defined structure of the result", 359 | ); 360 | } 361 | 362 | const row = row_data.reduce((row, raw_value, index) => { 363 | const current_column = this.rowDescription!.columns[index]; 364 | 365 | if (raw_value === null) { 366 | row[columns[index]] = null; 367 | } else { 368 | row[columns[index]] = decode(raw_value, current_column, controls); 369 | } 370 | 371 | return row; 372 | }, {} as Record); 373 | 374 | this.rows.push(row as T); 375 | } 376 | } 377 | 378 | /** 379 | * This class is used to handle the query to be executed by the database 380 | */ 381 | export class Query { 382 | public args: EncodedArg[]; 383 | public camelCase?: boolean; 384 | /** 385 | * The explicitly set fields for the query result, they have been validated beforehand 386 | * for duplicates and invalid names 387 | */ 388 | public fields?: string[]; 389 | // TODO 390 | // Should be private 391 | public result_type: ResultType; 392 | // TODO 393 | // Document that this text is the one sent to the database, not the original one 394 | public text: string; 395 | constructor(config: QueryObjectOptions, result_type: T); 396 | constructor(text: string, result_type: T, args?: QueryArguments); 397 | constructor( 398 | config_or_text: string | QueryObjectOptions, 399 | result_type: T, 400 | args: QueryArguments = [], 401 | ) { 402 | this.result_type = result_type; 403 | if (typeof config_or_text === "string") { 404 | if (!Array.isArray(args)) { 405 | [config_or_text, args] = objectQueryToQueryArgs(config_or_text, args); 406 | } 407 | 408 | this.text = config_or_text; 409 | this.args = args.map(encodeArgument); 410 | } else { 411 | const { camelCase, encoder = encodeArgument, fields } = config_or_text; 412 | let { args = [], text } = config_or_text; 413 | 414 | // Check that the fields passed are valid and can be used to map 415 | // the result of the query 416 | if (fields) { 417 | const fields_are_clean = fields.every((field) => 418 | /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(field) 419 | ); 420 | if (!fields_are_clean) { 421 | throw new TypeError( 422 | "The fields provided for the query must contain only letters and underscores", 423 | ); 424 | } 425 | 426 | if (new Set(fields).size !== fields.length) { 427 | throw new TypeError( 428 | "The fields provided for the query must be unique", 429 | ); 430 | } 431 | 432 | this.fields = fields; 433 | } 434 | 435 | this.camelCase = camelCase; 436 | 437 | if (!Array.isArray(args)) { 438 | [text, args] = objectQueryToQueryArgs(text, args); 439 | } 440 | 441 | this.args = args.map(encoder); 442 | this.text = text; 443 | } 444 | } 445 | } 446 | -------------------------------------------------------------------------------- /query/types.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * https://www.postgresql.org/docs/14/datatype-geometric.html#id-1.5.7.16.8 3 | */ 4 | export interface Box { 5 | a: Point; 6 | b: Point; 7 | } 8 | 9 | /** 10 | * https://www.postgresql.org/docs/14/datatype-geometric.html#DATATYPE-CIRCLE 11 | */ 12 | export interface Circle { 13 | point: Point; 14 | radius: Float8; 15 | } 16 | 17 | /** 18 | * Decimal-like string. Uses dot to split the decimal 19 | * 20 | * Example: 1.89, 2, 2.1 21 | * 22 | * https://www.postgresql.org/docs/14/datatype-numeric.html#DATATYPE-FLOAT 23 | */ 24 | export type Float4 = "string"; 25 | 26 | /** 27 | * Decimal-like string. Uses dot to split the decimal 28 | * 29 | * Example: 1.89, 2, 2.1 30 | * 31 | * https://www.postgresql.org/docs/14/datatype-numeric.html#DATATYPE-FLOAT 32 | */ 33 | export type Float8 = "string"; 34 | 35 | /** 36 | * https://www.postgresql.org/docs/14/datatype-geometric.html#DATATYPE-LINE 37 | */ 38 | export interface Line { 39 | a: Float8; 40 | b: Float8; 41 | c: Float8; 42 | } 43 | 44 | /** 45 | * https://www.postgresql.org/docs/14/datatype-geometric.html#DATATYPE-LSEG 46 | */ 47 | export interface LineSegment { 48 | a: Point; 49 | b: Point; 50 | } 51 | 52 | /** 53 | * https://www.postgresql.org/docs/14/datatype-geometric.html#id-1.5.7.16.9 54 | */ 55 | export type Path = Point[]; 56 | 57 | /** 58 | * https://www.postgresql.org/docs/14/datatype-geometric.html#id-1.5.7.16.5 59 | */ 60 | export interface Point { 61 | x: Float8; 62 | y: Float8; 63 | } 64 | 65 | /** 66 | * https://www.postgresql.org/docs/14/datatype-geometric.html#DATATYPE-POLYGON 67 | */ 68 | export type Polygon = Point[]; 69 | 70 | /** 71 | * https://www.postgresql.org/docs/14/datatype-oid.html 72 | */ 73 | export type TID = [bigint, bigint]; 74 | 75 | /** 76 | * Additional to containing normal dates, they can contain 'Infinity' 77 | * values, so handle them with care 78 | * 79 | * https://www.postgresql.org/docs/14/datatype-datetime.html 80 | */ 81 | export type Timestamp = Date | number; 82 | -------------------------------------------------------------------------------- /tests/README.md: -------------------------------------------------------------------------------- 1 | # Testing 2 | 3 | To run tests, we recommend using Docker. With Docker, there is no need to modify 4 | any configuration, just run the build and test commands. 5 | 6 | If running tests on your host, prepare your configuration file by copying 7 | `config.example.json` into `config.json` and updating it appropriately based on 8 | your environment. 9 | 10 | ## Running the Tests 11 | 12 | From within the project directory, run: 13 | 14 | ```sh 15 | # run on host 16 | deno test --allow-read --allow-net --allow-env 17 | 18 | # run in docker container 19 | docker compose build --no-cache 20 | docker compose run tests 21 | ``` 22 | 23 | ## Docker Configuration 24 | 25 | If you have Docker installed then you can run the following to set up a running 26 | container that is compatible with the tests: 27 | 28 | ```sh 29 | docker run --rm --env POSTGRES_USER=test --env POSTGRES_PASSWORD=test \ 30 | --env POSTGRES_DB=deno_postgres -p 5432:5432 postgres:12-alpine 31 | ``` 32 | -------------------------------------------------------------------------------- /tests/auth_test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | assertEquals, 3 | assertNotEquals, 4 | assertRejects, 5 | } from "jsr:@std/assert@1.0.10"; 6 | import { Client as ScramClient, Reason } from "../connection/scram.ts"; 7 | 8 | Deno.test("Scram client reproduces RFC 7677 example", async () => { 9 | // Example seen in https://tools.ietf.org/html/rfc7677 10 | const client = new ScramClient("user", "pencil", "rOprNGfwEbeRWgbNEkqO"); 11 | 12 | assertEquals( 13 | client.composeChallenge(), 14 | "n,,n=user,r=rOprNGfwEbeRWgbNEkqO", 15 | ); 16 | await client.receiveChallenge( 17 | "r=rOprNGfwEbeRWgbNEkqO%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0," + 18 | "s=W22ZaJ0SNY7soEsUEjb6gQ==,i=4096", 19 | ); 20 | assertEquals( 21 | await client.composeResponse(), 22 | "c=biws,r=rOprNGfwEbeRWgbNEkqO%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0," + 23 | "p=dHzbZapWIk4jUhN+Ute9ytag9zjfMHgsqmmiz7AndVQ=", 24 | ); 25 | await client.receiveResponse( 26 | "v=6rriTRBi23WpRR/wtup+mMhUZUn/dB5nLTJRsjl95G4=", 27 | ); 28 | }); 29 | 30 | Deno.test("Scram client catches bad server nonce", async () => { 31 | const testCases = [ 32 | "s=c2FsdA==,i=4096", // no server nonce 33 | "r=,s=c2FsdA==,i=4096", // empty 34 | "r=nonce2,s=c2FsdA==,i=4096", // not prefixed with client nonce 35 | ]; 36 | for (const testCase of testCases) { 37 | const client = new ScramClient("user", "password", "nonce1"); 38 | client.composeChallenge(); 39 | await assertRejects( 40 | () => client.receiveChallenge(testCase), 41 | Error, 42 | Reason.BadServerNonce, 43 | ); 44 | } 45 | }); 46 | 47 | Deno.test("Scram client catches bad salt", async () => { 48 | const testCases = [ 49 | "r=nonce12,i=4096", // no salt 50 | "r=nonce12,s=*,i=4096", // ill-formed base-64 string 51 | ]; 52 | for (const testCase of testCases) { 53 | const client = new ScramClient("user", "password", "nonce1"); 54 | client.composeChallenge(); 55 | await assertRejects( 56 | () => client.receiveChallenge(testCase), 57 | Error, 58 | Reason.BadSalt, 59 | ); 60 | } 61 | }); 62 | 63 | Deno.test("Scram client catches bad iteration count", async () => { 64 | const testCases = [ 65 | "r=nonce12,s=c2FsdA==", // no iteration count 66 | "r=nonce12,s=c2FsdA==,i=", // empty 67 | "r=nonce12,s=c2FsdA==,i=*", // not a number 68 | "r=nonce12,s=c2FsdA==,i=0", // non-positive integer 69 | "r=nonce12,s=c2FsdA==,i=-1", // non-positive integer 70 | ]; 71 | for (const testCase of testCases) { 72 | const client = new ScramClient("user", "password", "nonce1"); 73 | client.composeChallenge(); 74 | await assertRejects( 75 | () => client.receiveChallenge(testCase), 76 | Error, 77 | Reason.BadIterationCount, 78 | ); 79 | } 80 | }); 81 | 82 | Deno.test("Scram client catches bad verifier", async () => { 83 | const client = new ScramClient("user", "password", "nonce1"); 84 | client.composeChallenge(); 85 | await client.receiveChallenge("r=nonce12,s=c2FsdA==,i=4096"); 86 | await client.composeResponse(); 87 | await assertRejects( 88 | () => client.receiveResponse("v=xxxx"), 89 | Error, 90 | Reason.BadVerifier, 91 | ); 92 | }); 93 | 94 | Deno.test("Scram client catches server rejection", async () => { 95 | const client = new ScramClient("user", "password", "nonce1"); 96 | client.composeChallenge(); 97 | await client.receiveChallenge("r=nonce12,s=c2FsdA==,i=4096"); 98 | await client.composeResponse(); 99 | 100 | const message = "auth error"; 101 | await assertRejects( 102 | () => client.receiveResponse(`e=${message}`), 103 | Error, 104 | message, 105 | ); 106 | }); 107 | 108 | Deno.test("Scram client generates unique challenge", () => { 109 | const challenge1 = new ScramClient("user", "password").composeChallenge(); 110 | const challenge2 = new ScramClient("user", "password").composeChallenge(); 111 | assertNotEquals(challenge1, challenge2); 112 | }); 113 | -------------------------------------------------------------------------------- /tests/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "ci": { 3 | "postgres_clear": { 4 | "applicationName": "deno_postgres", 5 | "database": "postgres", 6 | "hostname": "postgres_clear", 7 | "password": "postgres", 8 | "port": 6000, 9 | "socket": "/var/run/postgres_clear", 10 | "users": { 11 | "clear": "clear", 12 | "socket": "socket" 13 | } 14 | }, 15 | "postgres_md5": { 16 | "applicationName": "deno_postgres", 17 | "database": "postgres", 18 | "hostname": "postgres_md5", 19 | "password": "postgres", 20 | "port": 6001, 21 | "socket": "/var/run/postgres_md5", 22 | "users": { 23 | "main": "postgres", 24 | "md5": "md5", 25 | "socket": "socket", 26 | "tls_only": "tls_only" 27 | } 28 | }, 29 | "postgres_scram": { 30 | "applicationName": "deno_postgres", 31 | "database": "postgres", 32 | "hostname": "postgres_scram", 33 | "password": "postgres", 34 | "port": 6002, 35 | "socket": "/var/run/postgres_scram", 36 | "users": { 37 | "scram": "scram", 38 | "socket": "socket" 39 | } 40 | } 41 | }, 42 | "local": { 43 | "postgres_clear": { 44 | "applicationName": "deno_postgres", 45 | "database": "postgres", 46 | "hostname": "localhost", 47 | "password": "postgres", 48 | "port": 6000, 49 | "socket": "/var/run/postgres_clear", 50 | "users": { 51 | "clear": "clear", 52 | "socket": "socket" 53 | } 54 | }, 55 | "postgres_md5": { 56 | "applicationName": "deno_postgres", 57 | "database": "postgres", 58 | "hostname": "localhost", 59 | "password": "postgres", 60 | "port": 6001, 61 | "socket": "/var/run/postgres_md5", 62 | "users": { 63 | "clear": "clear", 64 | "main": "postgres", 65 | "md5": "md5", 66 | "socket": "socket", 67 | "tls_only": "tls_only" 68 | } 69 | }, 70 | "postgres_scram": { 71 | "applicationName": "deno_postgres", 72 | "database": "postgres", 73 | "hostname": "localhost", 74 | "password": "postgres", 75 | "port": 6002, 76 | "socket": "/var/run/postgres_scram", 77 | "users": { 78 | "scram": "scram", 79 | "socket": "socket" 80 | } 81 | } 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /tests/config.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | ClientConfiguration, 3 | ClientOptions, 4 | } from "../connection/connection_params.ts"; 5 | import config_file1 from "./config.json" with { type: "json" }; 6 | 7 | type TcpConfiguration = Omit & { 8 | host_type: "tcp"; 9 | }; 10 | type SocketConfiguration = Omit & { 11 | host_type: "socket"; 12 | }; 13 | 14 | let DEV_MODE: string | undefined; 15 | try { 16 | DEV_MODE = Deno.env.get("DENO_POSTGRES_DEVELOPMENT"); 17 | } catch (e) { 18 | if ( 19 | e instanceof Deno.errors.PermissionDenied || 20 | ("NotCapable" in Deno.errors && e instanceof Deno.errors.NotCapable) 21 | ) { 22 | throw new Error( 23 | "You need to provide ENV access in order to run the test suite", 24 | ); 25 | } 26 | throw e; 27 | } 28 | const config = DEV_MODE === "true" ? config_file1.local : config_file1.ci; 29 | 30 | const enabled_tls = { 31 | caCertificates: [ 32 | Deno.readTextFileSync( 33 | new URL("../docker/certs/ca.crt", import.meta.url), 34 | ), 35 | ], 36 | enabled: true, 37 | enforce: true, 38 | }; 39 | 40 | const disabled_tls = { 41 | caCertificates: [], 42 | enabled: false, 43 | enforce: false, 44 | }; 45 | 46 | export const getClearConfiguration = ( 47 | tls: boolean, 48 | ): TcpConfiguration => { 49 | return { 50 | applicationName: config.postgres_clear.applicationName, 51 | database: config.postgres_clear.database, 52 | host_type: "tcp", 53 | hostname: config.postgres_clear.hostname, 54 | options: {}, 55 | password: config.postgres_clear.password, 56 | port: config.postgres_clear.port, 57 | tls: tls ? enabled_tls : disabled_tls, 58 | user: config.postgres_clear.users.clear, 59 | }; 60 | }; 61 | 62 | export const getClearSocketConfiguration = (): SocketConfiguration => { 63 | return { 64 | applicationName: config.postgres_clear.applicationName, 65 | database: config.postgres_clear.database, 66 | host_type: "socket", 67 | hostname: config.postgres_clear.socket, 68 | options: {}, 69 | password: config.postgres_clear.password, 70 | port: config.postgres_clear.port, 71 | user: config.postgres_clear.users.socket, 72 | }; 73 | }; 74 | 75 | /** MD5 authenticated user with privileged access to the database */ 76 | export const getMainConfiguration = ( 77 | _config?: ClientOptions, 78 | ): TcpConfiguration => { 79 | return { 80 | applicationName: config.postgres_md5.applicationName, 81 | database: config.postgres_md5.database, 82 | hostname: config.postgres_md5.hostname, 83 | password: config.postgres_md5.password, 84 | user: config.postgres_md5.users.main, 85 | ..._config, 86 | options: {}, 87 | port: config.postgres_md5.port, 88 | tls: enabled_tls, 89 | host_type: "tcp", 90 | }; 91 | }; 92 | 93 | export const getMd5Configuration = (tls: boolean): TcpConfiguration => { 94 | return { 95 | applicationName: config.postgres_md5.applicationName, 96 | database: config.postgres_md5.database, 97 | hostname: config.postgres_md5.hostname, 98 | host_type: "tcp", 99 | options: {}, 100 | password: config.postgres_md5.password, 101 | port: config.postgres_md5.port, 102 | tls: tls ? enabled_tls : disabled_tls, 103 | user: config.postgres_md5.users.md5, 104 | }; 105 | }; 106 | 107 | export const getMd5SocketConfiguration = (): SocketConfiguration => { 108 | return { 109 | applicationName: config.postgres_md5.applicationName, 110 | database: config.postgres_md5.database, 111 | hostname: config.postgres_md5.socket, 112 | host_type: "socket", 113 | options: {}, 114 | password: config.postgres_md5.password, 115 | port: config.postgres_md5.port, 116 | user: config.postgres_md5.users.socket, 117 | }; 118 | }; 119 | 120 | export const getScramConfiguration = (tls: boolean): TcpConfiguration => { 121 | return { 122 | applicationName: config.postgres_scram.applicationName, 123 | database: config.postgres_scram.database, 124 | hostname: config.postgres_scram.hostname, 125 | host_type: "tcp", 126 | options: {}, 127 | password: config.postgres_scram.password, 128 | port: config.postgres_scram.port, 129 | tls: tls ? enabled_tls : disabled_tls, 130 | user: config.postgres_scram.users.scram, 131 | }; 132 | }; 133 | 134 | export const getScramSocketConfiguration = (): SocketConfiguration => { 135 | return { 136 | applicationName: config.postgres_scram.applicationName, 137 | database: config.postgres_scram.database, 138 | hostname: config.postgres_scram.socket, 139 | host_type: "socket", 140 | options: {}, 141 | password: config.postgres_scram.password, 142 | port: config.postgres_scram.port, 143 | user: config.postgres_scram.users.socket, 144 | }; 145 | }; 146 | 147 | export const getTlsOnlyConfiguration = (): TcpConfiguration => { 148 | return { 149 | applicationName: config.postgres_md5.applicationName, 150 | database: config.postgres_md5.database, 151 | hostname: config.postgres_md5.hostname, 152 | host_type: "tcp", 153 | options: {}, 154 | password: config.postgres_md5.password, 155 | port: config.postgres_md5.port, 156 | tls: enabled_tls, 157 | user: config.postgres_md5.users.tls_only, 158 | }; 159 | }; 160 | -------------------------------------------------------------------------------- /tests/connection_params_test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals, assertThrows } from "jsr:@std/assert@1.0.10"; 2 | import { fromFileUrl } from "@std/path"; 3 | import { createParams } from "../connection/connection_params.ts"; 4 | import { ConnectionParamsError } from "../client/error.ts"; 5 | 6 | function setEnv(env: string, value?: string) { 7 | value ? Deno.env.set(env, value) : Deno.env.delete(env); 8 | } 9 | 10 | /** 11 | * This function is ment to be used as a container for env based tests. 12 | * It will mutate the env state and run the callback passed to it, then 13 | * reset the env variables to it's original state 14 | * 15 | * It can only be used in tests that run with env permissions 16 | */ 17 | function withEnv( 18 | { 19 | database, 20 | host, 21 | options, 22 | port, 23 | user, 24 | }: { 25 | database?: string; 26 | host?: string; 27 | options?: string; 28 | user?: string; 29 | port?: string; 30 | }, 31 | fn: (t: Deno.TestContext) => void, 32 | ): (t: Deno.TestContext) => void | Promise { 33 | return (t) => { 34 | const PGDATABASE = Deno.env.get("PGDATABASE"); 35 | const PGHOST = Deno.env.get("PGHOST"); 36 | const PGOPTIONS = Deno.env.get("PGOPTIONS"); 37 | const PGPORT = Deno.env.get("PGPORT"); 38 | const PGUSER = Deno.env.get("PGUSER"); 39 | 40 | database && Deno.env.set("PGDATABASE", database); 41 | host && Deno.env.set("PGHOST", host); 42 | options && Deno.env.set("PGOPTIONS", options); 43 | port && Deno.env.set("PGPORT", port); 44 | user && Deno.env.set("PGUSER", user); 45 | 46 | fn(t); 47 | 48 | // Reset to original state 49 | database && setEnv("PGDATABASE", PGDATABASE); 50 | host && setEnv("PGHOST", PGHOST); 51 | options && setEnv("PGOPTIONS", PGOPTIONS); 52 | port && setEnv("PGPORT", PGPORT); 53 | user && setEnv("PGUSER", PGUSER); 54 | }; 55 | } 56 | 57 | Deno.test("Parses connection string", function () { 58 | const p = createParams( 59 | "postgres://some_user@some_host:10101/deno_postgres", 60 | ); 61 | 62 | assertEquals(p.database, "deno_postgres"); 63 | assertEquals(p.host_type, "tcp"); 64 | assertEquals(p.hostname, "some_host"); 65 | assertEquals(p.port, 10101); 66 | assertEquals(p.user, "some_user"); 67 | }); 68 | 69 | Deno.test("Parses connection string with socket host", function () { 70 | const socket = "/var/run/postgresql"; 71 | 72 | const p = createParams( 73 | `postgres://some_user@${encodeURIComponent(socket)}:10101/deno_postgres`, 74 | ); 75 | 76 | assertEquals(p.database, "deno_postgres"); 77 | assertEquals(p.hostname, socket); 78 | assertEquals(p.host_type, "socket"); 79 | assertEquals(p.port, 10101); 80 | assertEquals(p.user, "some_user"); 81 | }); 82 | 83 | Deno.test('Parses connection string with "postgresql" as driver', function () { 84 | const p = createParams( 85 | "postgresql://some_user@some_host:10101/deno_postgres", 86 | ); 87 | 88 | assertEquals(p.database, "deno_postgres"); 89 | assertEquals(p.user, "some_user"); 90 | assertEquals(p.hostname, "some_host"); 91 | assertEquals(p.port, 10101); 92 | }); 93 | 94 | Deno.test("Parses connection string without port", function () { 95 | const p = createParams( 96 | "postgres://some_user@some_host/deno_postgres", 97 | ); 98 | 99 | assertEquals(p.database, "deno_postgres"); 100 | assertEquals(p.user, "some_user"); 101 | assertEquals(p.hostname, "some_host"); 102 | assertEquals(p.port, 5432); 103 | }); 104 | 105 | Deno.test("Parses connection string with application name", function () { 106 | const p = createParams( 107 | "postgres://some_user@some_host:10101/deno_postgres?application_name=test_app", 108 | ); 109 | 110 | assertEquals(p.database, "deno_postgres"); 111 | assertEquals(p.user, "some_user"); 112 | assertEquals(p.hostname, "some_host"); 113 | assertEquals(p.applicationName, "test_app"); 114 | assertEquals(p.port, 10101); 115 | }); 116 | 117 | Deno.test("Parses connection string with reserved URL parameters", () => { 118 | const p = createParams( 119 | "postgres://?dbname=some_db&user=some_user", 120 | ); 121 | 122 | assertEquals(p.database, "some_db"); 123 | assertEquals(p.user, "some_user"); 124 | }); 125 | 126 | Deno.test("Parses connection string with sslmode required", function () { 127 | const p = createParams( 128 | "postgres://some_user@some_host:10101/deno_postgres?sslmode=require", 129 | ); 130 | 131 | assertEquals(p.tls.enabled, true); 132 | assertEquals(p.tls.enforce, true); 133 | }); 134 | 135 | Deno.test("Parses connection string with options", () => { 136 | { 137 | const params = { 138 | x: "1", 139 | y: "2", 140 | }; 141 | 142 | const params_as_args = Object.entries(params).map(([key, value]) => 143 | `--${key}=${value}` 144 | ).join(" "); 145 | 146 | const p = createParams( 147 | `postgres://some_user@some_host:10101/deno_postgres?options=${ 148 | encodeURIComponent(params_as_args) 149 | }`, 150 | ); 151 | 152 | assertEquals(p.options, params); 153 | } 154 | 155 | // Test arguments provided with the -c flag 156 | { 157 | const params = { 158 | x: "1", 159 | y: "2", 160 | }; 161 | 162 | const params_as_args = Object.entries(params).map(([key, value]) => 163 | `-c ${key}=${value}` 164 | ).join(" "); 165 | 166 | const p = createParams( 167 | `postgres://some_user@some_host:10101/deno_postgres?options=${ 168 | encodeURIComponent(params_as_args) 169 | }`, 170 | ); 171 | 172 | assertEquals(p.options, params); 173 | } 174 | }); 175 | 176 | Deno.test("Throws on connection string with invalid options", () => { 177 | assertThrows( 178 | () => 179 | createParams( 180 | `postgres://some_user@some_host:10101/deno_postgres?options=z`, 181 | ), 182 | Error, 183 | `Value "z" is not a valid options argument`, 184 | ); 185 | 186 | assertThrows( 187 | () => 188 | createParams( 189 | `postgres://some_user@some_host:10101/deno_postgres?options=${ 190 | encodeURIComponent("-c") 191 | }`, 192 | ), 193 | Error, 194 | `No provided value for "-c" in options parameter`, 195 | ); 196 | 197 | assertThrows( 198 | () => 199 | createParams( 200 | `postgres://some_user@some_host:10101/deno_postgres?options=${ 201 | encodeURIComponent("-c a") 202 | }`, 203 | ), 204 | Error, 205 | `Value "a" is not a valid options argument`, 206 | ); 207 | 208 | assertThrows( 209 | () => 210 | createParams( 211 | `postgres://some_user@some_host:10101/deno_postgres?options=${ 212 | encodeURIComponent("-b a=1") 213 | }`, 214 | ), 215 | Error, 216 | `Argument "-b" is not supported in options parameter`, 217 | ); 218 | }); 219 | 220 | Deno.test("Throws on connection string with invalid driver", function () { 221 | assertThrows( 222 | () => 223 | createParams( 224 | "somedriver://some_user@some_host:10101/deno_postgres", 225 | ), 226 | Error, 227 | "Supplied DSN has invalid driver: somedriver.", 228 | ); 229 | }); 230 | 231 | Deno.test("Throws on connection string with invalid port", function () { 232 | assertThrows( 233 | () => 234 | createParams( 235 | "postgres://some_user@some_host:abc/deno_postgres", 236 | ), 237 | ConnectionParamsError, 238 | "Could not parse the connection string", 239 | ); 240 | }); 241 | 242 | Deno.test("Throws on connection string with invalid ssl mode", function () { 243 | assertThrows( 244 | () => 245 | createParams( 246 | "postgres://some_user@some_host:10101/deno_postgres?sslmode=invalid", 247 | ), 248 | ConnectionParamsError, 249 | "Supplied DSN has invalid sslmode 'invalid'", 250 | ); 251 | }); 252 | 253 | Deno.test("Parses connection options", function () { 254 | const p = createParams({ 255 | user: "some_user", 256 | hostname: "some_host", 257 | port: 10101, 258 | database: "deno_postgres", 259 | host_type: "tcp", 260 | }); 261 | 262 | assertEquals(p.database, "deno_postgres"); 263 | assertEquals(p.user, "some_user"); 264 | assertEquals(p.hostname, "some_host"); 265 | assertEquals(p.port, 10101); 266 | }); 267 | 268 | Deno.test("Throws on invalid tls options", function () { 269 | assertThrows( 270 | () => 271 | createParams({ 272 | host_type: "tcp", 273 | tls: { 274 | enabled: false, 275 | enforce: true, 276 | }, 277 | }), 278 | ConnectionParamsError, 279 | "Can't enforce TLS when client has TLS encryption is disabled", 280 | ); 281 | }); 282 | 283 | Deno.test( 284 | "Parses env connection options", 285 | withEnv({ 286 | database: "deno_postgres", 287 | host: "some_host", 288 | port: "10101", 289 | user: "some_user", 290 | }, () => { 291 | const p = createParams(); 292 | assertEquals(p.database, "deno_postgres"); 293 | assertEquals(p.hostname, "some_host"); 294 | assertEquals(p.port, 10101); 295 | assertEquals(p.user, "some_user"); 296 | }), 297 | ); 298 | 299 | Deno.test( 300 | "Parses options argument from env", 301 | withEnv({ 302 | database: "deno_postgres", 303 | user: "some_user", 304 | options: "-c a=1", 305 | }, () => { 306 | const p = createParams(); 307 | 308 | assertEquals(p.options, { a: "1" }); 309 | }), 310 | ); 311 | 312 | Deno.test( 313 | "Throws on env connection options with invalid port", 314 | withEnv({ 315 | database: "deno_postgres", 316 | host: "some_host", 317 | port: "abc", 318 | user: "some_user", 319 | }, () => { 320 | assertThrows( 321 | () => createParams(), 322 | ConnectionParamsError, 323 | `"abc" is not a valid port number`, 324 | ); 325 | }), 326 | ); 327 | 328 | Deno.test({ 329 | name: "Parses mixed connection options and env connection options", 330 | fn: () => { 331 | const p = createParams({ 332 | database: "deno_postgres", 333 | host_type: "tcp", 334 | user: "deno_postgres", 335 | }); 336 | 337 | assertEquals(p.database, "deno_postgres"); 338 | assertEquals(p.user, "deno_postgres"); 339 | assertEquals(p.hostname, "127.0.0.1"); 340 | assertEquals(p.port, 5432); 341 | }, 342 | permissions: { 343 | env: false, 344 | }, 345 | }); 346 | 347 | Deno.test({ 348 | name: "Throws if it can't obtain necessary parameters from config or env", 349 | fn: () => { 350 | assertThrows( 351 | () => createParams(), 352 | ConnectionParamsError, 353 | "Missing connection parameters: database, user", 354 | ); 355 | 356 | assertThrows( 357 | () => createParams({ user: "some_user" }), 358 | ConnectionParamsError, 359 | "Missing connection parameters: database", 360 | ); 361 | }, 362 | permissions: { 363 | env: false, 364 | }, 365 | }); 366 | 367 | Deno.test({ 368 | name: "Uses default connection options", 369 | fn: () => { 370 | const database = "deno_postgres"; 371 | const user = "deno_postgres"; 372 | 373 | const p = createParams({ 374 | database, 375 | host_type: "tcp", 376 | user, 377 | }); 378 | 379 | assertEquals(p.database, database); 380 | assertEquals(p.user, user); 381 | assertEquals( 382 | p.hostname, 383 | "127.0.0.1", 384 | ); 385 | assertEquals(p.port, 5432); 386 | assertEquals( 387 | p.password, 388 | undefined, 389 | ); 390 | }, 391 | permissions: { 392 | env: false, 393 | }, 394 | }); 395 | 396 | Deno.test({ 397 | name: "Throws when required options are not passed", 398 | fn: () => { 399 | assertThrows( 400 | () => createParams(), 401 | ConnectionParamsError, 402 | "Missing connection parameters:", 403 | ); 404 | }, 405 | permissions: { 406 | env: false, 407 | }, 408 | }); 409 | 410 | Deno.test("Determines host type", () => { 411 | { 412 | const p = createParams({ 413 | database: "some_db", 414 | hostname: "127.0.0.1", 415 | user: "some_user", 416 | }); 417 | 418 | assertEquals(p.host_type, "tcp"); 419 | } 420 | 421 | { 422 | const p = createParams( 423 | "postgres://somehost.com?dbname=some_db&user=some_user", 424 | ); 425 | assertEquals(p.hostname, "somehost.com"); 426 | assertEquals(p.host_type, "tcp"); 427 | } 428 | 429 | { 430 | const abs_path = "/some/absolute/path"; 431 | 432 | const p = createParams({ 433 | database: "some_db", 434 | hostname: abs_path, 435 | host_type: "socket", 436 | user: "some_user", 437 | }); 438 | 439 | assertEquals(p.hostname, abs_path); 440 | assertEquals(p.host_type, "socket"); 441 | } 442 | 443 | { 444 | const rel_path = "./some_file"; 445 | 446 | const p = createParams({ 447 | database: "some_db", 448 | hostname: rel_path, 449 | host_type: "socket", 450 | user: "some_user", 451 | }); 452 | 453 | assertEquals(p.hostname, fromFileUrl(new URL(rel_path, import.meta.url))); 454 | assertEquals(p.host_type, "socket"); 455 | } 456 | 457 | { 458 | const p = createParams("postgres://?dbname=some_db&user=some_user"); 459 | assertEquals(p.hostname, "/tmp"); 460 | assertEquals(p.host_type, "socket"); 461 | } 462 | }); 463 | 464 | Deno.test("Throws when TLS options and socket type are specified", () => { 465 | assertThrows( 466 | () => 467 | createParams({ 468 | database: "some_db", 469 | hostname: "./some_file", 470 | host_type: "socket", 471 | user: "some_user", 472 | tls: { 473 | enabled: true, 474 | }, 475 | }), 476 | ConnectionParamsError, 477 | `No TLS options are allowed when host type is set to "socket"`, 478 | ); 479 | }); 480 | 481 | Deno.test("Throws when host is a URL and host type is socket", () => { 482 | const error = assertThrows( 483 | () => 484 | createParams({ 485 | database: "some_db", 486 | hostname: "https://some_host.com", 487 | host_type: "socket", 488 | user: "some_user", 489 | }), 490 | ); 491 | 492 | if (!(error instanceof ConnectionParamsError)) { 493 | throw new Error(`Unexpected error: ${error}`); 494 | } 495 | 496 | if (!(error.cause instanceof Error)) { 497 | throw new Error(`Expected cause for error`); 498 | } 499 | 500 | const expected_message = "The provided host is not a file path"; 501 | if ( 502 | typeof error.cause.message !== "string" || 503 | !error.cause.message.includes(expected_message) 504 | ) { 505 | throw new Error( 506 | `Expected error cause to include "${expected_message}"`, 507 | ); 508 | } 509 | }); 510 | 511 | Deno.test("Escapes spaces on option values", () => { 512 | const value = "space here"; 513 | 514 | const p = createParams({ 515 | database: "some_db", 516 | user: "some_user", 517 | options: { 518 | "key": value, 519 | }, 520 | }); 521 | 522 | assertEquals(value.replaceAll(" ", "\\ "), p.options.key); 523 | }); 524 | 525 | Deno.test("Throws on invalid option keys", () => { 526 | assertThrows( 527 | () => 528 | createParams({ 529 | database: "some_db", 530 | user: "some_user", 531 | options: { 532 | "asd a": "a", 533 | }, 534 | }), 535 | Error, 536 | 'The "asd a" key in the options argument is invalid', 537 | ); 538 | }); 539 | -------------------------------------------------------------------------------- /tests/decode_test.ts: -------------------------------------------------------------------------------- 1 | import { Column, decode } from "../query/decode.ts"; 2 | import { 3 | decodeBigint, 4 | decodeBigintArray, 5 | decodeBoolean, 6 | decodeBooleanArray, 7 | decodeBox, 8 | decodeCircle, 9 | decodeDate, 10 | decodeDatetime, 11 | decodeFloat, 12 | decodeInt, 13 | decodeJson, 14 | decodeLine, 15 | decodeLineSegment, 16 | decodePath, 17 | decodePoint, 18 | decodeTid, 19 | } from "../query/decoders.ts"; 20 | import { assertEquals, assertThrows } from "jsr:@std/assert@1.0.10"; 21 | import { Oid } from "../query/oid.ts"; 22 | 23 | Deno.test("decodeBigint", function () { 24 | assertEquals(decodeBigint("18014398509481984"), 18014398509481984n); 25 | }); 26 | 27 | Deno.test("decodeBigintArray", function () { 28 | assertEquals( 29 | decodeBigintArray( 30 | "{17365398509481972,9007199254740992,-10414398509481984}", 31 | ), 32 | [17365398509481972n, 9007199254740992n, -10414398509481984n], 33 | ); 34 | }); 35 | 36 | Deno.test("decodeBoolean", function () { 37 | assertEquals(decodeBoolean("True"), true); 38 | assertEquals(decodeBoolean("yEs"), true); 39 | assertEquals(decodeBoolean("T"), true); 40 | assertEquals(decodeBoolean("t"), true); 41 | assertEquals(decodeBoolean("YeS"), true); 42 | assertEquals(decodeBoolean("On"), true); 43 | assertEquals(decodeBoolean("1"), true); 44 | assertEquals(decodeBoolean("no"), false); 45 | assertEquals(decodeBoolean("off"), false); 46 | assertEquals(decodeBoolean("0"), false); 47 | assertEquals(decodeBoolean("F"), false); 48 | assertEquals(decodeBoolean("false"), false); 49 | assertEquals(decodeBoolean("n"), false); 50 | assertEquals(decodeBoolean(""), false); 51 | }); 52 | 53 | Deno.test("decodeBooleanArray", function () { 54 | assertEquals(decodeBooleanArray("{True,0,T}"), [true, false, true]); 55 | assertEquals(decodeBooleanArray("{no,Y,1}"), [false, true, true]); 56 | }); 57 | 58 | Deno.test("decodeBox", function () { 59 | assertEquals(decodeBox("(12.4,2),(33,4.33)"), { 60 | a: { x: "12.4", y: "2" }, 61 | b: { x: "33", y: "4.33" }, 62 | }); 63 | let testValue = "(12.4,2)"; 64 | assertThrows( 65 | () => decodeBox(testValue), 66 | Error, 67 | `Invalid Box: "${testValue}". Box must have only 2 point, 1 given.`, 68 | ); 69 | testValue = "(12.4,2),(123,123,123),(9303,33)"; 70 | assertThrows( 71 | () => decodeBox(testValue), 72 | Error, 73 | `Invalid Box: "${testValue}". Box must have only 2 point, 3 given.`, 74 | ); 75 | testValue = "(0,0),(123,123,123)"; 76 | assertThrows( 77 | () => decodeBox(testValue), 78 | Error, 79 | `Invalid Box: "${testValue}" : Invalid Point: "(123,123,123)". Points must have only 2 coordinates, 3 given.`, 80 | ); 81 | testValue = "(0,0),(100,r100)"; 82 | assertThrows( 83 | () => decodeBox(testValue), 84 | Error, 85 | `Invalid Box: "${testValue}" : Invalid Point: "(100,r100)". Coordinate "r100" must be a valid number.`, 86 | ); 87 | }); 88 | 89 | Deno.test("decodeCircle", function () { 90 | assertEquals(decodeCircle("<(12.4,2),3.5>"), { 91 | point: { x: "12.4", y: "2" }, 92 | radius: "3.5", 93 | }); 94 | let testValue = "<(c21 23,2),3.5>"; 95 | assertThrows( 96 | () => decodeCircle(testValue), 97 | Error, 98 | `Invalid Circle: "${testValue}" : Invalid Point: "(c21 23,2)". Coordinate "c21 23" must be a valid number.`, 99 | ); 100 | testValue = "<(33,2),mn23 3.5>"; 101 | assertThrows( 102 | () => decodeCircle(testValue), 103 | Error, 104 | `Invalid Circle: "${testValue}". Circle radius "mn23 3.5" must be a valid number.`, 105 | ); 106 | }); 107 | 108 | Deno.test("decodeDate", function () { 109 | assertEquals(decodeDate("2021-08-01"), new Date("2021-08-01 00:00:00-00")); 110 | }); 111 | 112 | Deno.test("decodeDatetime", function () { 113 | assertEquals( 114 | decodeDatetime("2021-08-01"), 115 | new Date("2021-08-01 00:00:00-00"), 116 | ); 117 | assertEquals( 118 | decodeDatetime("1997-12-17 07:37:16-08"), 119 | new Date("1997-12-17 07:37:16-08"), 120 | ); 121 | }); 122 | 123 | Deno.test("decodeFloat", function () { 124 | assertEquals(decodeFloat("3.14"), 3.14); 125 | assertEquals(decodeFloat("q743 44 23i4"), NaN); 126 | }); 127 | 128 | Deno.test("decodeInt", function () { 129 | assertEquals(decodeInt("42"), 42); 130 | assertEquals(decodeInt("q743 44 23i4"), NaN); 131 | }); 132 | 133 | Deno.test("decodeJson", function () { 134 | assertEquals( 135 | decodeJson( 136 | '{"key_1": "MY VALUE", "key_2": null, "key_3": 10, "key_4": {"subkey_1": true, "subkey_2": ["1",2]}}', 137 | ), 138 | { 139 | key_1: "MY VALUE", 140 | key_2: null, 141 | key_3: 10, 142 | key_4: { subkey_1: true, subkey_2: ["1", 2] }, 143 | }, 144 | ); 145 | assertThrows(() => decodeJson("{ 'eqw' ; ddd}")); 146 | }); 147 | 148 | Deno.test("decodeLine", function () { 149 | assertEquals(decodeLine("{100,50,0}"), { a: "100", b: "50", c: "0" }); 150 | let testValue = "{100,50,0,100}"; 151 | assertThrows( 152 | () => decodeLine("{100,50,0,100}"), 153 | Error, 154 | `Invalid Line: "${testValue}". Line in linear equation format must have 3 constants, 4 given.`, 155 | ); 156 | testValue = "{100,d3km,0}"; 157 | assertThrows( 158 | () => decodeLine(testValue), 159 | Error, 160 | `Invalid Line: "${testValue}". Line constant "d3km" must be a valid number.`, 161 | ); 162 | }); 163 | 164 | Deno.test("decodeLineSegment", function () { 165 | assertEquals(decodeLineSegment("((100,50),(350,350))"), { 166 | a: { x: "100", y: "50" }, 167 | b: { x: "350", y: "350" }, 168 | }); 169 | let testValue = "((100,50),(r344,350))"; 170 | assertThrows( 171 | () => decodeLineSegment(testValue), 172 | Error, 173 | `Invalid Line Segment: "${testValue}" : Invalid Point: "(r344,350)". Coordinate "r344" must be a valid number.`, 174 | ); 175 | testValue = "((100),(r344,350))"; 176 | assertThrows( 177 | () => decodeLineSegment(testValue), 178 | Error, 179 | `Invalid Line Segment: "${testValue}" : Invalid Point: "(100)". Points must have only 2 coordinates, 1 given.`, 180 | ); 181 | testValue = "((100,50))"; 182 | assertThrows( 183 | () => decodeLineSegment(testValue), 184 | Error, 185 | `Invalid Line Segment: "${testValue}". Line segments must have only 2 point, 1 given.`, 186 | ); 187 | testValue = "((100,50),(350,350),(100,100))"; 188 | assertThrows( 189 | () => decodeLineSegment(testValue), 190 | Error, 191 | `Invalid Line Segment: "${testValue}". Line segments must have only 2 point, 3 given.`, 192 | ); 193 | }); 194 | 195 | Deno.test("decodePath", function () { 196 | assertEquals(decodePath("[(100,50),(350,350)]"), [ 197 | { x: "100", y: "50" }, 198 | { x: "350", y: "350" }, 199 | ]); 200 | assertEquals(decodePath("[(1,10),(2,20),(3,30)]"), [ 201 | { x: "1", y: "10" }, 202 | { x: "2", y: "20" }, 203 | { x: "3", y: "30" }, 204 | ]); 205 | let testValue = "((100,50),(350,kjf334))"; 206 | assertThrows( 207 | () => decodePath(testValue), 208 | Error, 209 | `Invalid Path: "${testValue}" : Invalid Point: "(350,kjf334)". Coordinate "kjf334" must be a valid number.`, 210 | ); 211 | testValue = "((100,50,9949))"; 212 | assertThrows( 213 | () => decodePath(testValue), 214 | Error, 215 | `Invalid Path: "${testValue}" : Invalid Point: "(100,50,9949)". Points must have only 2 coordinates, 3 given.`, 216 | ); 217 | }); 218 | 219 | Deno.test("decodePoint", function () { 220 | assertEquals(decodePoint("(10.555,50.8)"), { x: "10.555", y: "50.8" }); 221 | let testValue = "(1000)"; 222 | assertThrows( 223 | () => decodePoint(testValue), 224 | Error, 225 | `Invalid Point: "${testValue}". Points must have only 2 coordinates, 1 given.`, 226 | ); 227 | testValue = "(100.100,50,350)"; 228 | assertThrows( 229 | () => decodePoint(testValue), 230 | Error, 231 | `Invalid Point: "${testValue}". Points must have only 2 coordinates, 3 given.`, 232 | ); 233 | testValue = "(1,r344)"; 234 | assertThrows( 235 | () => decodePoint(testValue), 236 | Error, 237 | `Invalid Point: "${testValue}". Coordinate "r344" must be a valid number.`, 238 | ); 239 | testValue = "(cd 213ee,100)"; 240 | assertThrows( 241 | () => decodePoint(testValue), 242 | Error, 243 | `Invalid Point: "${testValue}". Coordinate "cd 213ee" must be a valid number.`, 244 | ); 245 | }); 246 | 247 | Deno.test("decodeTid", function () { 248 | assertEquals(decodeTid("(19714398509481984,29383838509481984)"), [ 249 | 19714398509481984n, 250 | 29383838509481984n, 251 | ]); 252 | }); 253 | 254 | Deno.test("decode strategy", function () { 255 | const testValues = [ 256 | { 257 | value: "40", 258 | column: new Column("test", 0, 0, Oid.int4, 0, 0, 0), 259 | parsed: 40, 260 | }, 261 | { 262 | value: "my_value", 263 | column: new Column("test", 0, 0, Oid.text, 0, 0, 0), 264 | parsed: "my_value", 265 | }, 266 | { 267 | value: "[(100,50),(350,350)]", 268 | column: new Column("test", 0, 0, Oid.path, 0, 0, 0), 269 | parsed: [ 270 | { x: "100", y: "50" }, 271 | { x: "350", y: "350" }, 272 | ], 273 | }, 274 | { 275 | value: '{"value_1","value_2","value_3"}', 276 | column: new Column("test", 0, 0, Oid.text_array, 0, 0, 0), 277 | parsed: ["value_1", "value_2", "value_3"], 278 | }, 279 | { 280 | value: "1997-12-17 07:37:16-08", 281 | column: new Column("test", 0, 0, Oid.timestamp, 0, 0, 0), 282 | parsed: new Date("1997-12-17 07:37:16-08"), 283 | }, 284 | { 285 | value: "Yes", 286 | column: new Column("test", 0, 0, Oid.bool, 0, 0, 0), 287 | parsed: true, 288 | }, 289 | { 290 | value: "<(12.4,2),3.5>", 291 | column: new Column("test", 0, 0, Oid.circle, 0, 0, 0), 292 | parsed: { point: { x: "12.4", y: "2" }, radius: "3.5" }, 293 | }, 294 | { 295 | value: '{"test":1,"val":"foo","example":[1,2,false]}', 296 | column: new Column("test", 0, 0, Oid.jsonb, 0, 0, 0), 297 | parsed: { test: 1, val: "foo", example: [1, 2, false] }, 298 | }, 299 | { 300 | value: "18014398509481984", 301 | column: new Column("test", 0, 0, Oid.int8, 0, 0, 0), 302 | parsed: 18014398509481984n, 303 | }, 304 | { 305 | value: "{3.14,1.11,0.43,200}", 306 | column: new Column("test", 0, 0, Oid.float4_array, 0, 0, 0), 307 | parsed: [3.14, 1.11, 0.43, 200], 308 | }, 309 | ]; 310 | 311 | for (const testValue of testValues) { 312 | const encodedValue = new TextEncoder().encode(testValue.value); 313 | 314 | // check default behavior 315 | assertEquals(decode(encodedValue, testValue.column), testValue.parsed); 316 | // check 'auto' behavior 317 | assertEquals( 318 | decode(encodedValue, testValue.column, { decodeStrategy: "auto" }), 319 | testValue.parsed, 320 | ); 321 | // check 'string' behavior 322 | assertEquals( 323 | decode(encodedValue, testValue.column, { decodeStrategy: "string" }), 324 | testValue.value, 325 | ); 326 | } 327 | }); 328 | -------------------------------------------------------------------------------- /tests/encode_test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals } from "jsr:@std/assert@1.0.10"; 2 | import { encodeArgument } from "../query/encode.ts"; 3 | 4 | // internally `encodeArguments` uses `getTimezoneOffset` to encode Date 5 | // so for testing purposes we'll be overriding it 6 | const _getTimezoneOffset = Date.prototype.getTimezoneOffset; 7 | 8 | function resetTimezoneOffset() { 9 | Date.prototype.getTimezoneOffset = _getTimezoneOffset; 10 | } 11 | 12 | function overrideTimezoneOffset(offset: number) { 13 | Date.prototype.getTimezoneOffset = function () { 14 | return offset; 15 | }; 16 | } 17 | 18 | Deno.test("encodeDatetime", function () { 19 | // GMT 20 | overrideTimezoneOffset(0); 21 | 22 | const gmtDate = new Date(2019, 1, 10, 20, 30, 40, 5); 23 | const gmtEncoded = encodeArgument(gmtDate); 24 | assertEquals(gmtEncoded, "2019-02-10T20:30:40.005+00:00"); 25 | 26 | resetTimezoneOffset(); 27 | 28 | // GMT+02:30 29 | overrideTimezoneOffset(-150); 30 | 31 | const date = new Date(2019, 1, 10, 20, 30, 40, 5); 32 | const encoded = encodeArgument(date); 33 | assertEquals(encoded, "2019-02-10T20:30:40.005+02:30"); 34 | 35 | resetTimezoneOffset(); 36 | }); 37 | 38 | Deno.test("encodeUndefined", function () { 39 | assertEquals(encodeArgument(undefined), null); 40 | }); 41 | 42 | Deno.test("encodeNull", function () { 43 | assertEquals(encodeArgument(null), null); 44 | }); 45 | 46 | Deno.test("encodeBoolean", function () { 47 | assertEquals(encodeArgument(true), "true"); 48 | assertEquals(encodeArgument(false), "false"); 49 | }); 50 | 51 | Deno.test("encodeNumber", function () { 52 | assertEquals(encodeArgument(1), "1"); 53 | assertEquals(encodeArgument(1.2345), "1.2345"); 54 | }); 55 | 56 | Deno.test("encodeString", function () { 57 | assertEquals(encodeArgument("deno-postgres"), "deno-postgres"); 58 | }); 59 | 60 | Deno.test("encodeObject", function () { 61 | assertEquals(encodeArgument({ x: 1 }), '{"x":1}'); 62 | }); 63 | 64 | Deno.test("encodeUint8Array", function () { 65 | const buf1 = new Uint8Array([1, 2, 3]); 66 | const buf2 = new Uint8Array([2, 10, 500]); 67 | const buf3 = new Uint8Array([11]); 68 | 69 | assertEquals("\\x010203", encodeArgument(buf1)); 70 | assertEquals("\\x020af4", encodeArgument(buf2)); 71 | assertEquals("\\x0b", encodeArgument(buf3)); 72 | }); 73 | 74 | Deno.test("encodeArray", function () { 75 | const array = [null, "postgres", 1, ["foo", "bar"]]; 76 | const encodedArray = encodeArgument(array); 77 | 78 | assertEquals(encodedArray, '{NULL,"postgres","1",{"foo","bar"}}'); 79 | }); 80 | 81 | Deno.test("encodeObjectArray", function () { 82 | const array = [{ x: 1 }, { y: 2 }]; 83 | const encodedArray = encodeArgument(array); 84 | assertEquals(encodedArray, '{"{\\"x\\":1}","{\\"y\\":2}"}'); 85 | }); 86 | 87 | Deno.test("encodeDateArray", function () { 88 | overrideTimezoneOffset(0); 89 | 90 | const array = [new Date(2019, 1, 10, 20, 30, 40, 5)]; 91 | const encodedArray = encodeArgument(array); 92 | assertEquals(encodedArray, '{"2019-02-10T20:30:40.005+00:00"}'); 93 | 94 | resetTimezoneOffset(); 95 | }); 96 | -------------------------------------------------------------------------------- /tests/helpers.ts: -------------------------------------------------------------------------------- 1 | import { Client } from "../client.ts"; 2 | import { Pool } from "../pool.ts"; 3 | import type { ClientOptions } from "../connection/connection_params.ts"; 4 | 5 | export function generateSimpleClientTest( 6 | client_options: ClientOptions, 7 | ) { 8 | return function testSimpleClient( 9 | test_function: (client: Client) => Promise, 10 | ): () => Promise { 11 | return async () => { 12 | const client = new Client(client_options); 13 | try { 14 | await client.connect(); 15 | await test_function(client); 16 | } finally { 17 | await client.end(); 18 | } 19 | }; 20 | }; 21 | } 22 | 23 | export function generatePoolClientTest(client_options: ClientOptions) { 24 | return function generatePoolClientTest1( 25 | test_function: (pool: Pool, size: number, lazy: boolean) => Promise, 26 | size = 10, 27 | lazy = false, 28 | ) { 29 | return async () => { 30 | const pool = new Pool(client_options, size, lazy); 31 | // If the connection is not lazy, create a client to await 32 | // for initialization 33 | if (!lazy) { 34 | const client = await pool.connect(); 35 | client.release(); 36 | } 37 | try { 38 | await test_function(pool, size, lazy); 39 | } finally { 40 | await pool.end(); 41 | } 42 | }; 43 | }; 44 | } 45 | -------------------------------------------------------------------------------- /tests/pool_test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals } from "jsr:@std/assert@1.0.10"; 2 | import { getMainConfiguration } from "./config.ts"; 3 | import { generatePoolClientTest } from "./helpers.ts"; 4 | 5 | const testPool = generatePoolClientTest(getMainConfiguration()); 6 | 7 | Deno.test( 8 | "Pool handles simultaneous connections correcly", 9 | testPool( 10 | async (POOL) => { 11 | assertEquals(POOL.available, 10); 12 | const client = await POOL.connect(); 13 | const p = client.queryArray("SELECT pg_sleep(0.1) is null, -1 AS id"); 14 | await new Promise((resolve) => setTimeout(resolve, 1)); 15 | assertEquals(POOL.available, 9); 16 | assertEquals(POOL.size, 10); 17 | await p; 18 | client.release(); 19 | assertEquals(POOL.available, 10); 20 | 21 | const qsThunks = [...Array(25)].map(async (_, i) => { 22 | const client = await POOL.connect(); 23 | const query = await client.queryArray( 24 | "SELECT pg_sleep(0.1) is null, $1::text as id", 25 | [i], 26 | ); 27 | client.release(); 28 | return query; 29 | }); 30 | const qsPromises = Promise.all(qsThunks); 31 | await new Promise((resolve) => setTimeout(resolve, 1)); 32 | assertEquals(POOL.available, 0); 33 | const qs = await qsPromises; 34 | assertEquals(POOL.available, 10); 35 | assertEquals(POOL.size, 10); 36 | 37 | const result = qs.map((r) => r.rows[0][1]); 38 | const expected = [...Array(25)].map((_, i) => i.toString()); 39 | assertEquals(result, expected); 40 | }, 41 | ), 42 | ); 43 | 44 | Deno.test( 45 | "Pool initializes lazy connections on demand", 46 | testPool( 47 | async (POOL, size) => { 48 | const client_1 = await POOL.connect(); 49 | await client_1.queryArray("SELECT 1"); 50 | await client_1.release(); 51 | assertEquals(await POOL.initialized(), 1); 52 | 53 | const client_2 = await POOL.connect(); 54 | const p = client_2.queryArray("SELECT pg_sleep(0.1) is null, -1 AS id"); 55 | await new Promise((resolve) => setTimeout(resolve, 1)); 56 | assertEquals(POOL.size, size); 57 | assertEquals(POOL.available, size - 1); 58 | assertEquals(await POOL.initialized(), 0); 59 | await p; 60 | await client_2.release(); 61 | assertEquals(await POOL.initialized(), 1); 62 | 63 | // Test stack repletion as well 64 | const requested_clients = size + 5; 65 | const qsThunks = Array.from( 66 | { length: requested_clients }, 67 | async (_, i) => { 68 | const client = await POOL.connect(); 69 | const query = await client.queryArray( 70 | "SELECT pg_sleep(0.1) is null, $1::text as id", 71 | [i], 72 | ); 73 | client.release(); 74 | return query; 75 | }, 76 | ); 77 | const qsPromises = Promise.all(qsThunks); 78 | await new Promise((resolve) => setTimeout(resolve, 1)); 79 | assertEquals(POOL.available, 0); 80 | assertEquals(await POOL.initialized(), 0); 81 | const qs = await qsPromises; 82 | assertEquals(POOL.available, size); 83 | assertEquals(await POOL.initialized(), size); 84 | 85 | const result = qs.map((r) => r.rows[0][1]); 86 | const expected = Array.from( 87 | { length: requested_clients }, 88 | (_, i) => i.toString(), 89 | ); 90 | assertEquals(result, expected); 91 | }, 92 | 10, 93 | true, 94 | ), 95 | ); 96 | 97 | Deno.test( 98 | "Pool can be reinitialized after termination", 99 | testPool(async (POOL) => { 100 | await POOL.end(); 101 | assertEquals(POOL.available, 0); 102 | 103 | const client = await POOL.connect(); 104 | await client.queryArray`SELECT 1`; 105 | client.release(); 106 | assertEquals(POOL.available, 10); 107 | }), 108 | ); 109 | 110 | Deno.test( 111 | "Lazy pool can be reinitialized after termination", 112 | testPool( 113 | async (POOL, size) => { 114 | await POOL.end(); 115 | assertEquals(POOL.available, 0); 116 | assertEquals(await POOL.initialized(), 0); 117 | 118 | const client = await POOL.connect(); 119 | await client.queryArray`SELECT 1`; 120 | client.release(); 121 | assertEquals(await POOL.initialized(), 1); 122 | assertEquals(POOL.available, size); 123 | }, 124 | 10, 125 | true, 126 | ), 127 | ); 128 | 129 | Deno.test( 130 | "Concurrent connect-then-release cycles do not throw", 131 | testPool(async (POOL) => { 132 | async function connectThenRelease() { 133 | let client = await POOL.connect(); 134 | client.release(); 135 | client = await POOL.connect(); 136 | client.release(); 137 | } 138 | await Promise.all( 139 | Array.from({ length: POOL.size + 1 }, connectThenRelease), 140 | ); 141 | }), 142 | ); 143 | 144 | Deno.test( 145 | "Pool client will be released after `using` block", 146 | testPool(async (POOL) => { 147 | const initialPoolAvailable = POOL.available; 148 | { 149 | using _client = await POOL.connect(); 150 | assertEquals(POOL.available, initialPoolAvailable - 1); 151 | } 152 | assertEquals(POOL.available, initialPoolAvailable); 153 | }), 154 | ); 155 | -------------------------------------------------------------------------------- /tests/test_deps.ts: -------------------------------------------------------------------------------- 1 | export { 2 | assert, 3 | assertEquals, 4 | assertInstanceOf, 5 | assertNotEquals, 6 | assertObjectMatch, 7 | assertRejects, 8 | assertThrows, 9 | } from "jsr:@std/assert@1.0.10"; 10 | -------------------------------------------------------------------------------- /tests/utils_test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals, assertThrows } from "jsr:@std/assert@1.0.10"; 2 | import { parseConnectionUri, type Uri } from "../utils/utils.ts"; 3 | import { DeferredAccessStack, DeferredStack } from "../utils/deferred.ts"; 4 | 5 | class LazilyInitializedObject { 6 | #initialized = false; 7 | 8 | // Simulate async check 9 | get initialized() { 10 | return new Promise((r) => r(this.#initialized)); 11 | } 12 | 13 | async initialize(): Promise { 14 | // Fake delay 15 | await new Promise((resolve) => { 16 | setTimeout(() => { 17 | resolve(); 18 | }, 10); 19 | }); 20 | 21 | this.#initialized = true; 22 | } 23 | } 24 | 25 | const dns_examples: Partial[] = [ 26 | { driver: "postgresql", host: "localhost" }, 27 | { driver: "postgresql", host: "localhost", port: "5433" }, 28 | { driver: "postgresql", host: "localhost", port: "5433", path: "mydb" }, 29 | { driver: "postgresql", host: "localhost", path: "mydb" }, 30 | { driver: "postgresql", host: "localhost", user: "user" }, 31 | { driver: "postgresql", host: "localhost", password: "secret" }, 32 | { driver: "postgresql", host: "localhost", user: "user", password: "secret" }, 33 | { 34 | driver: "postgresql", 35 | host: "localhost", 36 | user: "user", 37 | password: "secret", 38 | params: { "param_1": "a" }, 39 | }, 40 | { 41 | driver: "postgresql", 42 | host: "localhost", 43 | user: "user", 44 | password: "secret", 45 | path: "otherdb", 46 | params: { "param_1": "a" }, 47 | }, 48 | { 49 | driver: "postgresql", 50 | path: "otherdb", 51 | params: { "param_1": "a" }, 52 | }, 53 | { 54 | driver: "postgresql", 55 | host: "[2001:db8::1234]", 56 | }, 57 | { 58 | driver: "postgresql", 59 | host: "[2001:db8::1234]", 60 | port: "1500", 61 | }, 62 | { 63 | driver: "postgresql", 64 | host: "[2001:db8::1234]", 65 | port: "1500", 66 | params: { "param_1": "a" }, 67 | }, 68 | ]; 69 | 70 | Deno.test("Parses connection string into config", async function (context) { 71 | for ( 72 | const { 73 | driver, 74 | user = "", 75 | host = "", 76 | params = {}, 77 | password = "", 78 | path = "", 79 | port = "", 80 | } of dns_examples 81 | ) { 82 | const url_params = new URLSearchParams(); 83 | for (const key in params) { 84 | url_params.set(key, params[key]); 85 | } 86 | 87 | const dirty_dns = 88 | `${driver}://${user}:${password}@${host}:${port}/${path}?${url_params.toString()}`; 89 | 90 | await context.step(dirty_dns, () => { 91 | const parsed_dirty_dsn = parseConnectionUri(dirty_dns); 92 | 93 | assertEquals(parsed_dirty_dsn.driver, driver); 94 | assertEquals(parsed_dirty_dsn.host, host); 95 | assertEquals(parsed_dirty_dsn.params, params); 96 | assertEquals(parsed_dirty_dsn.password, password); 97 | assertEquals(parsed_dirty_dsn.path, path); 98 | assertEquals(parsed_dirty_dsn.port, port); 99 | assertEquals(parsed_dirty_dsn.user, user); 100 | }); 101 | 102 | // Build the URL without leaving placeholders 103 | let clean_dns_string = `${driver}://`; 104 | if (user || password) { 105 | clean_dns_string += `${user ?? ""}${password ? `:${password}` : ""}@`; 106 | } 107 | if (host || port) { 108 | clean_dns_string += `${host ?? ""}${port ? `:${port}` : ""}`; 109 | } 110 | if (path) { 111 | clean_dns_string += `/${path}`; 112 | } 113 | if (Object.keys(params).length > 0) { 114 | clean_dns_string += `?${url_params.toString()}`; 115 | } 116 | 117 | await context.step(clean_dns_string, () => { 118 | const parsed_clean_dsn = parseConnectionUri(clean_dns_string); 119 | 120 | assertEquals(parsed_clean_dsn.driver, driver); 121 | assertEquals(parsed_clean_dsn.host, host); 122 | assertEquals(parsed_clean_dsn.params, params); 123 | assertEquals(parsed_clean_dsn.password, password); 124 | assertEquals(parsed_clean_dsn.path, path); 125 | assertEquals(parsed_clean_dsn.port, port); 126 | assertEquals(parsed_clean_dsn.user, user); 127 | }); 128 | } 129 | }); 130 | 131 | Deno.test("Throws on invalid parameters", () => { 132 | assertThrows( 133 | () => parseConnectionUri("postgres://some_host:invalid"), 134 | Error, 135 | `The provided port "invalid" is not a valid number`, 136 | ); 137 | }); 138 | 139 | Deno.test("Parses connection string params into param object", function () { 140 | const params = { 141 | param_1: "asd", 142 | param_2: "xyz", 143 | param_3: "3541", 144 | }; 145 | 146 | const base_url = new URL("postgres://fizz:buzz@deno.land:8000/test_database"); 147 | for (const [key, value] of Object.entries(params)) { 148 | base_url.searchParams.set(key, value); 149 | } 150 | 151 | const parsed_dsn = parseConnectionUri(base_url.toString()); 152 | 153 | assertEquals(parsed_dsn.params, params); 154 | }); 155 | 156 | const encoded_hosts = ["/var/user/postgres", "./some_other_route"]; 157 | const encoded_passwords = ["Mtx=", "pássword!=?with_symbols"]; 158 | 159 | Deno.test("Decodes connection string values correctly", async (context) => { 160 | await context.step("Host", () => { 161 | for (const host of encoded_hosts) { 162 | assertEquals( 163 | parseConnectionUri( 164 | `postgres://${encodeURIComponent(host)}:9999/txdb`, 165 | ).host, 166 | host, 167 | ); 168 | } 169 | }); 170 | 171 | await context.step("Password", () => { 172 | for (const pwd of encoded_passwords) { 173 | assertEquals( 174 | parseConnectionUri( 175 | `postgres://root:${encodeURIComponent(pwd)}@localhost:9999/txdb`, 176 | ).password, 177 | pwd, 178 | ); 179 | } 180 | }); 181 | }); 182 | 183 | const invalid_hosts = ["Mtx%3", "%E0%A4%A.socket"]; 184 | const invalid_passwords = ["Mtx%3", "%E0%A4%A"]; 185 | 186 | Deno.test("Defaults to connection string literal if decoding fails", async (context) => { 187 | await context.step("Host", () => { 188 | for (const host of invalid_hosts) { 189 | assertEquals( 190 | parseConnectionUri( 191 | `postgres://${host}`, 192 | ).host, 193 | host, 194 | ); 195 | } 196 | }); 197 | 198 | await context.step("Password", () => { 199 | for (const pwd of invalid_passwords) { 200 | assertEquals( 201 | parseConnectionUri( 202 | `postgres://root:${pwd}@localhost:9999/txdb`, 203 | ).password, 204 | pwd, 205 | ); 206 | } 207 | }); 208 | }); 209 | 210 | Deno.test("DeferredStack", async () => { 211 | const stack = new DeferredStack( 212 | 10, 213 | [], 214 | () => new Promise((r) => r(undefined)), 215 | ); 216 | 217 | assertEquals(stack.size, 0); 218 | assertEquals(stack.available, 0); 219 | 220 | const item = await stack.pop(); 221 | assertEquals(stack.size, 1); 222 | assertEquals(stack.available, 0); 223 | 224 | stack.push(item); 225 | assertEquals(stack.size, 1); 226 | assertEquals(stack.available, 1); 227 | }); 228 | 229 | Deno.test("An empty DeferredStack awaits until an object is back in the stack", async () => { 230 | const stack = new DeferredStack( 231 | 1, 232 | [], 233 | () => new Promise((r) => r(undefined)), 234 | ); 235 | 236 | const a = await stack.pop(); 237 | let fulfilled = false; 238 | const b = stack.pop() 239 | .then((e) => { 240 | fulfilled = true; 241 | return e; 242 | }); 243 | 244 | await new Promise((r) => setTimeout(r, 100)); 245 | assertEquals(fulfilled, false); 246 | 247 | stack.push(a); 248 | assertEquals(a, await b); 249 | assertEquals(fulfilled, true); 250 | }); 251 | 252 | Deno.test("DeferredAccessStack", async () => { 253 | const stack_size = 10; 254 | 255 | const stack = new DeferredAccessStack( 256 | Array.from({ length: stack_size }, () => new LazilyInitializedObject()), 257 | (e) => e.initialize(), 258 | (e) => e.initialized, 259 | ); 260 | 261 | assertEquals(stack.size, stack_size); 262 | assertEquals(stack.available, stack_size); 263 | assertEquals(await stack.initialized(), 0); 264 | 265 | const a = await stack.pop(); 266 | assertEquals(await a.initialized, true); 267 | assertEquals(stack.size, stack_size); 268 | assertEquals(stack.available, stack_size - 1); 269 | assertEquals(await stack.initialized(), 0); 270 | 271 | stack.push(a); 272 | assertEquals(stack.size, stack_size); 273 | assertEquals(stack.available, stack_size); 274 | assertEquals(await stack.initialized(), 1); 275 | }); 276 | 277 | Deno.test("An empty DeferredAccessStack awaits until an object is back in the stack", async () => { 278 | const stack_size = 1; 279 | 280 | const stack = new DeferredAccessStack( 281 | Array.from({ length: stack_size }, () => new LazilyInitializedObject()), 282 | (e) => e.initialize(), 283 | (e) => e.initialized, 284 | ); 285 | 286 | const a = await stack.pop(); 287 | let fulfilled = false; 288 | const b = stack.pop() 289 | .then((e) => { 290 | fulfilled = true; 291 | return e; 292 | }); 293 | 294 | await new Promise((r) => setTimeout(r, 100)); 295 | assertEquals(fulfilled, false); 296 | 297 | stack.push(a); 298 | assertEquals(a, await b); 299 | assertEquals(fulfilled, true); 300 | }); 301 | -------------------------------------------------------------------------------- /tests/workers/postgres_server.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | const server = Deno.listen({ port: 8080 }); 5 | 6 | onmessage = ({ data }: { data: "initialize" | "close" }) => { 7 | switch (data) { 8 | case "initialize": { 9 | listenServerConnections(); 10 | postMessage("initialized"); 11 | break; 12 | } 13 | case "close": { 14 | server.close(); 15 | postMessage("closed"); 16 | break; 17 | } 18 | default: { 19 | throw new Error(`Unexpected message "${data}" received on worker`); 20 | } 21 | } 22 | }; 23 | 24 | async function listenServerConnections() { 25 | for await (const conn of server) { 26 | // The driver will attempt to check if the server receives 27 | // a TLS connection, however we return an invalid response 28 | conn.write(new TextEncoder().encode("INVALID")); 29 | // Notify the parent thread that we have received a connection 30 | postMessage("connection"); 31 | } 32 | } 33 | 34 | export {}; 35 | -------------------------------------------------------------------------------- /utils/deferred.ts: -------------------------------------------------------------------------------- 1 | export type Deferred = ReturnType>; 2 | 3 | export class DeferredStack { 4 | #elements: Array; 5 | #creator?: () => Promise; 6 | #max_size: number; 7 | #queue: Array>; 8 | #size: number; 9 | 10 | constructor(max?: number, ls?: Iterable, creator?: () => Promise) { 11 | this.#elements = ls ? [...ls] : []; 12 | this.#creator = creator; 13 | this.#max_size = max || 10; 14 | this.#queue = []; 15 | this.#size = this.#elements.length; 16 | } 17 | 18 | get available(): number { 19 | return this.#elements.length; 20 | } 21 | 22 | async pop(): Promise { 23 | if (this.#elements.length > 0) { 24 | return this.#elements.pop()!; 25 | } 26 | 27 | if (this.#size < this.#max_size && this.#creator) { 28 | this.#size++; 29 | return await this.#creator(); 30 | } 31 | const d = Promise.withResolvers(); 32 | this.#queue.push(d); 33 | return await d.promise; 34 | } 35 | 36 | push(value: T): void { 37 | if (this.#queue.length > 0) { 38 | const d = this.#queue.shift()!; 39 | d.resolve(value); 40 | } else { 41 | this.#elements.push(value); 42 | } 43 | } 44 | 45 | get size(): number { 46 | return this.#size; 47 | } 48 | } 49 | 50 | /** 51 | * The DeferredAccessStack provides access to a series of elements provided on the stack creation, 52 | * but with the caveat that they require an initialization of sorts before they can be used 53 | * 54 | * Instead of providing a `creator` function as you would with the `DeferredStack`, you provide 55 | * an initialization callback to execute for each element that is retrieved from the stack and a check 56 | * callback to determine if the element requires initialization and return a count of the initialized 57 | * elements 58 | */ 59 | export class DeferredAccessStack { 60 | #elements: Array; 61 | #initializeElement: (element: T) => Promise; 62 | #checkElementInitialization: (element: T) => Promise | boolean; 63 | #queue: Array>; 64 | #size: number; 65 | 66 | get available(): number { 67 | return this.#elements.length; 68 | } 69 | 70 | /** 71 | * The max number of elements that can be contained in the stack a time 72 | */ 73 | get size(): number { 74 | return this.#size; 75 | } 76 | 77 | /** 78 | * @param initialize This function will execute for each element that hasn't been initialized when requested from the stack 79 | */ 80 | constructor( 81 | elements: T[], 82 | initCallback: (element: T) => Promise, 83 | checkInitCallback: (element: T) => Promise | boolean, 84 | ) { 85 | this.#checkElementInitialization = checkInitCallback; 86 | this.#elements = elements; 87 | this.#initializeElement = initCallback; 88 | this.#queue = []; 89 | this.#size = elements.length; 90 | } 91 | 92 | /** 93 | * Will execute the check for initialization on each element of the stack 94 | * and then return the number of initialized elements that pass the check 95 | */ 96 | async initialized(): Promise { 97 | const initialized = await Promise.all( 98 | this.#elements.map((e) => this.#checkElementInitialization(e)), 99 | ); 100 | 101 | return initialized.filter((initialized) => initialized === true).length; 102 | } 103 | 104 | async pop(): Promise { 105 | let element: T; 106 | if (this.available > 0) { 107 | element = this.#elements.pop()!; 108 | } else { 109 | // If there are not elements left in the stack, it will await the call until 110 | // at least one is restored and then return it 111 | const d = Promise.withResolvers(); 112 | this.#queue.push(d); 113 | element = await d.promise; 114 | } 115 | 116 | if (!(await this.#checkElementInitialization(element))) { 117 | await this.#initializeElement(element); 118 | } 119 | return element; 120 | } 121 | 122 | push(value: T): void { 123 | // If an element has been requested while the stack was empty, indicate 124 | // that an element has been restored 125 | if (this.#queue.length > 0) { 126 | const d = this.#queue.shift()!; 127 | d.resolve(value); 128 | } else { 129 | this.#elements.push(value); 130 | } 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /utils/utils.ts: -------------------------------------------------------------------------------- 1 | import { bold, yellow } from "@std/fmt/colors"; 2 | 3 | export function readInt16BE(buffer: Uint8Array, offset: number): number { 4 | offset = offset >>> 0; 5 | const val = buffer[offset + 1] | (buffer[offset] << 8); 6 | return val & 0x8000 ? val | 0xffff0000 : val; 7 | } 8 | 9 | export function readUInt16BE(buffer: Uint8Array, offset: number): number { 10 | offset = offset >>> 0; 11 | return buffer[offset] | (buffer[offset + 1] << 8); 12 | } 13 | 14 | export function readInt32BE(buffer: Uint8Array, offset: number): number { 15 | offset = offset >>> 0; 16 | 17 | return ( 18 | (buffer[offset] << 24) | 19 | (buffer[offset + 1] << 16) | 20 | (buffer[offset + 2] << 8) | 21 | buffer[offset + 3] 22 | ); 23 | } 24 | 25 | export function readUInt32BE(buffer: Uint8Array, offset: number): number { 26 | offset = offset >>> 0; 27 | 28 | return ( 29 | buffer[offset] * 0x1000000 + 30 | ((buffer[offset + 1] << 16) | 31 | (buffer[offset + 2] << 8) | 32 | buffer[offset + 3]) 33 | ); 34 | } 35 | 36 | export interface Uri { 37 | driver: string; 38 | host: string; 39 | password: string; 40 | path: string; 41 | params: Record; 42 | port: string; 43 | user: string; 44 | } 45 | 46 | type ConnectionInfo = { 47 | driver?: string; 48 | user?: string; 49 | password?: string; 50 | full_host?: string; 51 | path?: string; 52 | params?: string; 53 | }; 54 | 55 | type ParsedHost = { 56 | host?: string; 57 | port?: string; 58 | }; 59 | 60 | /** 61 | * This function parses valid connection strings according to https://www.postgresql.org/docs/14/libpq-connect.html#LIBPQ-CONNSTRING 62 | * 63 | * The only exception to this rule are multi-host connection strings 64 | */ 65 | export function parseConnectionUri(uri: string): Uri { 66 | const parsed_uri = uri.match( 67 | /(?\w+):\/{2}((?[^\/?#\s:]+?)?(:(?[^\/?#\s]+)?)?@)?(?[^\/?#\s]+)?(\/(?[^?#\s]*))?(\?(?[^#\s]+))?.*/, 68 | ); 69 | if (!parsed_uri) throw new Error("Could not parse the provided URL"); 70 | 71 | let { 72 | driver = "", 73 | full_host = "", 74 | params = "", 75 | password = "", 76 | path = "", 77 | user = "", 78 | }: ConnectionInfo = parsed_uri.groups ?? {}; 79 | 80 | const parsed_host = full_host.match( 81 | /(?(\[.+\])|(.*?))(:(?[\w]*))?$/, 82 | ); 83 | if (!parsed_host) throw new Error(`Could not parse "${full_host}" host`); 84 | 85 | let { 86 | host = "", 87 | port = "", 88 | }: ParsedHost = parsed_host.groups ?? {}; 89 | 90 | try { 91 | if (host) { 92 | host = decodeURIComponent(host); 93 | } 94 | } catch (_e) { 95 | console.error( 96 | bold(`${yellow("Failed to decode URL host")}\nDefaulting to raw host`), 97 | ); 98 | } 99 | 100 | if (port && Number.isNaN(Number(port))) { 101 | throw new Error(`The provided port "${port}" is not a valid number`); 102 | } 103 | 104 | try { 105 | if (password) { 106 | password = decodeURIComponent(password); 107 | } 108 | } catch (_e) { 109 | console.error( 110 | bold( 111 | `${ 112 | yellow("Failed to decode URL password") 113 | }\nDefaulting to raw password`, 114 | ), 115 | ); 116 | } 117 | 118 | return { 119 | driver, 120 | host, 121 | params: Object.fromEntries(new URLSearchParams(params).entries()), 122 | password, 123 | path, 124 | port, 125 | user, 126 | }; 127 | } 128 | 129 | export function isTemplateString( 130 | template: unknown, 131 | ): template is TemplateStringsArray { 132 | if (!Array.isArray(template)) { 133 | return false; 134 | } 135 | return true; 136 | } 137 | 138 | /** 139 | * https://www.postgresql.org/docs/14/runtime-config-connection.html#RUNTIME-CONFIG-CONNECTION-SETTINGS 140 | * unix_socket_directories 141 | */ 142 | export const getSocketName = (port: number) => `.s.PGSQL.${port}`; 143 | --------------------------------------------------------------------------------