├── .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 | 
11 | [](https://discord.com/invite/HEdTCvZUSf)
12 | [](https://jsr.io/@db/postgres)
13 | [](https://jsr.io/@db/postgres)
14 | [](https://deno-postgres.com)
15 | [](https://jsr.io/@db/postgres/doc)
16 | [](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 |
--------------------------------------------------------------------------------