pgsql-test 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +64 -0
- package/__tests__/postgres-test.connections.test.ts +81 -0
- package/__tests__/postgres-test.grants.test.ts +53 -0
- package/__tests__/postgres-test.records.test.ts +66 -0
- package/__tests__/postgres-test.template.test.ts +52 -0
- package/__tests__/postgres-test.test.ts +36 -0
- package/jest.config.js +18 -0
- package/package.json +36 -0
- package/sql/roles.sql +48 -0
- package/sql/test.sql +36 -0
- package/src/admin.ts +135 -0
- package/src/connect.ts +42 -0
- package/src/index.ts +2 -0
- package/src/legacy-connect.ts +34 -0
- package/src/manager.ts +142 -0
- package/src/stream.ts +61 -0
- package/src/test-client.ts +113 -0
- package/src/utils.ts +48 -0
- package/test-utils/index.ts +2 -0
- package/tsconfig.esm.json +9 -0
- package/tsconfig.json +9 -0
package/README.md
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# pgsql-test
|
|
2
|
+
|
|
3
|
+
<p align="center">
|
|
4
|
+
<img src="https://user-images.githubusercontent.com/545047/188804067-28e67e5e-0214-4449-ab04-2e0c564a6885.svg" width="80"><br />
|
|
5
|
+
PostgreSQL Testing in TypeScript
|
|
6
|
+
</p>
|
|
7
|
+
|
|
8
|
+
## install
|
|
9
|
+
|
|
10
|
+
```sh
|
|
11
|
+
npm install pgsql-test
|
|
12
|
+
```
|
|
13
|
+
## Table of contents
|
|
14
|
+
|
|
15
|
+
- [pgsql-test](#pgsql-test)
|
|
16
|
+
- [Install](#install)
|
|
17
|
+
- [Table of contents](#table-of-contents)
|
|
18
|
+
- [Developing](#developing)
|
|
19
|
+
- [Credits](#credits)
|
|
20
|
+
|
|
21
|
+
## Developing
|
|
22
|
+
|
|
23
|
+
When first cloning the repo:
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
yarn
|
|
27
|
+
# build the prod packages. When devs would like to navigate to the source code, this will only navigate from references to their definitions (.d.ts files) between packages.
|
|
28
|
+
yarn build
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Or if you want to make your dev process smoother, you can run:
|
|
32
|
+
|
|
33
|
+
```sh
|
|
34
|
+
yarn
|
|
35
|
+
# build the dev packages with .map files, this enables navigation from references to their source code between packages.
|
|
36
|
+
yarn build:dev
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Interchain JavaScript Stack
|
|
40
|
+
|
|
41
|
+
A unified toolkit for building applications and smart contracts in the Interchain ecosystem ⚛️
|
|
42
|
+
|
|
43
|
+
| Category | Tools | Description |
|
|
44
|
+
|----------------------|------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------|
|
|
45
|
+
| **Chain Information** | [**Chain Registry**](https://github.com/hyperweb-io/chain-registry), [**Utils**](https://www.npmjs.com/package/@chain-registry/utils), [**Client**](https://www.npmjs.com/package/@chain-registry/client) | Everything from token symbols, logos, and IBC denominations for all assets you want to support in your application. |
|
|
46
|
+
| **Wallet Connectors**| [**Interchain Kit**](https://github.com/hyperweb-io/interchain-kit)<sup>beta</sup>, [**Cosmos Kit**](https://github.com/hyperweb.io/cosmos-kit) | Experience the convenience of connecting with a variety of web3 wallets through a single, streamlined interface. |
|
|
47
|
+
| **Signing Clients** | [**InterchainJS**](https://github.com/hyperweb-io/interchainjs)<sup>beta</sup>, [**CosmJS**](https://github.com/cosmos/cosmjs) | A single, universal signing interface for any network |
|
|
48
|
+
| **SDK Clients** | [**Telescope**](https://github.com/hyperweb.io/telescope) | Your Frontend Companion for Building with TypeScript with Cosmos SDK Modules. |
|
|
49
|
+
| **Starter Kits** | [**Create Interchain App**](https://github.com/hyperweb-io/create-interchain-app)<sup>beta</sup>, [**Create Cosmos App**](https://github.com/hyperweb.io/create-cosmos-app) | Set up a modern Interchain app by running one command. |
|
|
50
|
+
| **UI Kits** | [**Interchain UI**](https://github.com/hyperweb.io/interchain-ui) | The Interchain Design System, empowering developers with a flexible, easy-to-use UI kit. |
|
|
51
|
+
| **Testing Frameworks** | [**Starship**](https://github.com/hyperweb.io/starship) | Unified Testing and Development for the Interchain. |
|
|
52
|
+
| **TypeScript Smart Contracts** | [**Create Hyperweb App**](https://github.com/hyperweb-io/create-hyperweb-app) | Build and deploy full-stack blockchain applications with TypeScript |
|
|
53
|
+
| **CosmWasm Contracts** | [**CosmWasm TS Codegen**](https://github.com/CosmWasm/ts-codegen) | Convert your CosmWasm smart contracts into dev-friendly TypeScript classes. |
|
|
54
|
+
|
|
55
|
+
## Credits
|
|
56
|
+
|
|
57
|
+
🛠 Built by Hyperweb (formerly Cosmology) — if you like our tools, please checkout and contribute to [our github ⚛️](https://github.com/hyperweb-io)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
## Disclaimer
|
|
61
|
+
|
|
62
|
+
AS DESCRIBED IN THE LICENSES, THE SOFTWARE IS PROVIDED “AS IS”, AT YOUR OWN RISK, AND WITHOUT WARRANTIES OF ANY KIND.
|
|
63
|
+
|
|
64
|
+
No developer or entity involved in creating this software will be liable for any claims or damages whatsoever associated with your use, inability to use, or your interaction with other users of the code, including any direct, indirect, incidental, special, exemplary, punitive or consequential damages, or loss of profits, cryptocurrencies, tokens, or anything else of value.
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { getConnections } from '../src/connect';
|
|
2
|
+
import { PgTestClient } from '../src/test-client';
|
|
3
|
+
|
|
4
|
+
let conn: PgTestClient;
|
|
5
|
+
let db: PgTestClient;
|
|
6
|
+
let teardown: () => Promise<void>;
|
|
7
|
+
|
|
8
|
+
beforeAll(async () => {
|
|
9
|
+
({ conn, db, teardown } = await getConnections());
|
|
10
|
+
|
|
11
|
+
// Setup schema + seed ONCE globally
|
|
12
|
+
await db.query(`
|
|
13
|
+
CREATE TABLE users (
|
|
14
|
+
id SERIAL PRIMARY KEY,
|
|
15
|
+
name TEXT NOT NULL
|
|
16
|
+
);
|
|
17
|
+
CREATE TABLE posts (
|
|
18
|
+
id SERIAL PRIMARY KEY,
|
|
19
|
+
user_id INT NOT NULL REFERENCES users(id),
|
|
20
|
+
content TEXT NOT NULL
|
|
21
|
+
);
|
|
22
|
+
`);
|
|
23
|
+
|
|
24
|
+
await db.query(`
|
|
25
|
+
INSERT INTO users (name) VALUES ('Alice'), ('Bob');
|
|
26
|
+
INSERT INTO posts (user_id, content) VALUES
|
|
27
|
+
(1, 'Hello world!'),
|
|
28
|
+
(2, 'Graphile is cool!');
|
|
29
|
+
`);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
afterAll(async () => {
|
|
33
|
+
await teardown();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe('anonymous', () => {
|
|
37
|
+
beforeEach(async () => {
|
|
38
|
+
await db.beforeEach(); // this starts tx + savepoint
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
afterEach(async () => {
|
|
42
|
+
await db.afterEach(); // this rolls back and commits
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('inserts a user but rollback leaves baseline intact', async () => {
|
|
46
|
+
await db.query(`INSERT INTO users (name) VALUES ('Carol')`);
|
|
47
|
+
const res = await db.query('SELECT COUNT(*) FROM users');
|
|
48
|
+
expect(res.rows[0].count).toBe('3');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('should still have 2 users after rollback', async () => {
|
|
52
|
+
const res = await db.query('SELECT COUNT(*) FROM users');
|
|
53
|
+
expect(res.rows[0].count).toBe('2');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('runs under anonymous context', async () => {
|
|
57
|
+
const result = await conn.query('SELECT current_setting(\'role\', true) AS role');
|
|
58
|
+
console.log(JSON.stringify({result}, null, 2))
|
|
59
|
+
console.error(JSON.stringify({result}, null, 2))
|
|
60
|
+
// expect(result.rows[0].role).toBe('anonymous');
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe('authenticated', () => {
|
|
65
|
+
beforeEach(async () => {
|
|
66
|
+
conn.setContext({
|
|
67
|
+
role: 'authenticated'
|
|
68
|
+
});
|
|
69
|
+
await conn.beforeEach(); // required for rollback later
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
afterEach(async () => {
|
|
73
|
+
await conn.afterEach(); // now safe to rollback
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('runs under authenticated context', async () => {
|
|
77
|
+
const result = await conn.query('SELECT current_setting(\'role\', true) AS role');
|
|
78
|
+
// expect(result.rows[0].role).toBe('authenticated');
|
|
79
|
+
console.error('why no JWT')
|
|
80
|
+
});
|
|
81
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { getPgEnvOptions, PgConfig } from '@launchql/types';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
getConnection,
|
|
5
|
+
Connection,
|
|
6
|
+
} from '../src';
|
|
7
|
+
import { randomUUID } from 'crypto';
|
|
8
|
+
import { PgTestClient } from '../src/test-client';
|
|
9
|
+
import { DbAdmin } from '../src/admin';
|
|
10
|
+
import { resolve } from 'path';
|
|
11
|
+
|
|
12
|
+
const sql = (file: string) => resolve(__dirname, '../sql', file);
|
|
13
|
+
|
|
14
|
+
const TEST_DB_BASE = `postgres_test_${randomUUID()}`;
|
|
15
|
+
|
|
16
|
+
function setupBaseDB(config: PgConfig): void {
|
|
17
|
+
const admin = new DbAdmin(config);
|
|
18
|
+
admin.create(config.database)
|
|
19
|
+
admin.loadSql(sql('test.sql'), config.database);
|
|
20
|
+
admin.loadSql(sql('roles.sql'), config.database);
|
|
21
|
+
admin.drop(config.database);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const config = getPgEnvOptions({
|
|
25
|
+
database: TEST_DB_BASE
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
beforeAll(() => {
|
|
29
|
+
setupBaseDB(config);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
afterAll(() => {
|
|
33
|
+
Connection.getManager().closeAll();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
describe('Postgres Test Framework', () => {
|
|
38
|
+
let db: PgTestClient;
|
|
39
|
+
|
|
40
|
+
afterEach(() => {
|
|
41
|
+
// if (db) closeConnection(db);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('creates a test DB with hot mode (FAST_TEST)', () => {
|
|
45
|
+
db = getConnection({ hot: true, extensions: ['uuid-ossp'] });
|
|
46
|
+
expect(db).toBeDefined();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('creates a test DB from scratch (default)', () => {
|
|
50
|
+
db = getConnection({});
|
|
51
|
+
expect(db).toBeDefined();
|
|
52
|
+
});
|
|
53
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { getConnections } from '../src/connect';
|
|
2
|
+
import { PgTestClient } from '../src/test-client';
|
|
3
|
+
|
|
4
|
+
let conn: PgTestClient;
|
|
5
|
+
let db: PgTestClient;
|
|
6
|
+
let teardown: () => Promise<void>;
|
|
7
|
+
|
|
8
|
+
const setupSchemaSQL = `
|
|
9
|
+
CREATE TABLE users (
|
|
10
|
+
id SERIAL PRIMARY KEY,
|
|
11
|
+
name TEXT NOT NULL
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
CREATE TABLE posts (
|
|
15
|
+
id SERIAL PRIMARY KEY,
|
|
16
|
+
user_id INT NOT NULL REFERENCES users(id),
|
|
17
|
+
content TEXT NOT NULL
|
|
18
|
+
);
|
|
19
|
+
`;
|
|
20
|
+
|
|
21
|
+
const seedDataSQL = `
|
|
22
|
+
INSERT INTO users (name) VALUES ('Alice'), ('Bob');
|
|
23
|
+
INSERT INTO posts (user_id, content) VALUES
|
|
24
|
+
(1, 'Hello world!'),
|
|
25
|
+
(2, 'Graphile is cool!');
|
|
26
|
+
`;
|
|
27
|
+
|
|
28
|
+
beforeAll(async () => {
|
|
29
|
+
({ conn, db, teardown } = await getConnections());
|
|
30
|
+
// create schema + seed *once*
|
|
31
|
+
await db.query(setupSchemaSQL);
|
|
32
|
+
await db.query(seedDataSQL);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
afterAll(async () => {
|
|
36
|
+
await teardown();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe('Postgres Test Framework', () => {
|
|
40
|
+
beforeEach(async () => {
|
|
41
|
+
await db.beforeEach(); // BEGIN + SAVEPOINT
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
afterEach(async () => {
|
|
45
|
+
await db.afterEach(); // ROLLBACK TO SAVEPOINT + COMMIT
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('should have 2 users initially', async () => {
|
|
49
|
+
const { rows } = await db.query('SELECT COUNT(*) FROM users');
|
|
50
|
+
expect(rows[0].count).toBe('2');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('inserts a user but rollback leaves baseline intact', async () => {
|
|
54
|
+
await db.query(`INSERT INTO users (name) VALUES ('Carol')`);
|
|
55
|
+
let res = await db.query('SELECT COUNT(*) FROM users');
|
|
56
|
+
expect(res.rows[0].count).toBe('3'); // inside this tx
|
|
57
|
+
|
|
58
|
+
// after rollback (next test) we’ll still see 2
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('still sees 2 users after previous insert test', async () => {
|
|
62
|
+
const { rows } = await db.query('SELECT COUNT(*) FROM users');
|
|
63
|
+
expect(rows[0].count).toBe('2');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { getPgEnvOptions, PgConfig } from '@launchql/types';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
getConnection,
|
|
6
|
+
Connection
|
|
7
|
+
} from '../src';
|
|
8
|
+
import { PgTestClient } from '../src/test-client';
|
|
9
|
+
import { DbAdmin } from '../src/admin';
|
|
10
|
+
|
|
11
|
+
const sql = (file: string) => path.resolve(__dirname, '../sql', file);
|
|
12
|
+
|
|
13
|
+
const TEMPLATE_NAME = 'test_template';
|
|
14
|
+
const TEST_DB_BASE = 'postgres_test_db_template';
|
|
15
|
+
|
|
16
|
+
function setupTemplateDB(config: PgConfig, template: string): void {
|
|
17
|
+
const admin = new DbAdmin(config);
|
|
18
|
+
try {
|
|
19
|
+
admin.drop(config.database);
|
|
20
|
+
} catch {}
|
|
21
|
+
admin.create(config.database);
|
|
22
|
+
admin.loadSql(sql('test.sql'), config.database);
|
|
23
|
+
admin.loadSql(sql('roles.sql'), config.database);
|
|
24
|
+
admin.cleanupTemplate(template);
|
|
25
|
+
admin.createTemplateFromBase(config.database, template);
|
|
26
|
+
admin.drop(config.database);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const config = getPgEnvOptions({
|
|
30
|
+
database: TEST_DB_BASE
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
beforeAll(() => {
|
|
34
|
+
setupTemplateDB(config, TEMPLATE_NAME);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
afterAll(() => {
|
|
38
|
+
Connection.getManager().closeAll();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe('Template Database Test', () => {
|
|
42
|
+
let db: PgTestClient;
|
|
43
|
+
|
|
44
|
+
afterEach(() => {
|
|
45
|
+
// if (db) closeConnection(db);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('creates a test DB from a template', () => {
|
|
49
|
+
db = getConnection({ template: TEMPLATE_NAME });
|
|
50
|
+
expect(db).toBeDefined();
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { getPgEnvOptions, PgConfig } from '@launchql/types';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
getConnection,
|
|
5
|
+
Connection
|
|
6
|
+
} from '../src';
|
|
7
|
+
import { randomUUID } from 'crypto';
|
|
8
|
+
import { PgTestClient } from '../src/test-client';
|
|
9
|
+
|
|
10
|
+
const TEST_DB_BASE = `postgres_test_${randomUUID()}`;
|
|
11
|
+
|
|
12
|
+
const config = getPgEnvOptions({
|
|
13
|
+
database: TEST_DB_BASE
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
afterAll(() => {
|
|
17
|
+
Connection.getManager().closeAll();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe('Postgres Test Framework', () => {
|
|
21
|
+
let db: PgTestClient;
|
|
22
|
+
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
// if (db) closeConnection(db);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('creates a test DB with hot mode (FAST_TEST)', () => {
|
|
28
|
+
db = getConnection({ hot: true, extensions: ['uuid-ossp'] });
|
|
29
|
+
expect(db).toBeDefined();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('creates a test DB from scratch (default)', () => {
|
|
33
|
+
db = getConnection({});
|
|
34
|
+
expect(db).toBeDefined();
|
|
35
|
+
});
|
|
36
|
+
});
|
package/jest.config.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** @type {import('ts-jest').JestConfigWithTsJest} */
|
|
2
|
+
module.exports = {
|
|
3
|
+
preset: "ts-jest",
|
|
4
|
+
testEnvironment: "node",
|
|
5
|
+
transform: {
|
|
6
|
+
"^.+\\.tsx?$": [
|
|
7
|
+
"ts-jest",
|
|
8
|
+
{
|
|
9
|
+
babelConfig: false,
|
|
10
|
+
tsconfig: "tsconfig.json",
|
|
11
|
+
},
|
|
12
|
+
],
|
|
13
|
+
},
|
|
14
|
+
transformIgnorePatterns: [`/node_modules/*`],
|
|
15
|
+
testRegex: "(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$",
|
|
16
|
+
moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json", "node"],
|
|
17
|
+
modulePathIgnorePatterns: ["dist/*"]
|
|
18
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pgsql-test",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"author": "Dan Lynch <pyramation@gmail.com>",
|
|
5
|
+
"description": "PostgreSQL Testing in TypeScript",
|
|
6
|
+
"main": "index.js",
|
|
7
|
+
"module": "esm/index.js",
|
|
8
|
+
"types": "index.d.ts",
|
|
9
|
+
"homepage": "https://github.com/launchql/launchql",
|
|
10
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
11
|
+
"publishConfig": {
|
|
12
|
+
"access": "public",
|
|
13
|
+
"directory": "dist"
|
|
14
|
+
},
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "https://github.com/launchql/launchql"
|
|
18
|
+
},
|
|
19
|
+
"bugs": {
|
|
20
|
+
"url": "https://github.com/launchql/launchql/issues"
|
|
21
|
+
},
|
|
22
|
+
"scripts": {
|
|
23
|
+
"copy": "copyfiles -f ../../LICENSE README.md package.json dist",
|
|
24
|
+
"clean": "rimraf dist/**",
|
|
25
|
+
"prepare": "npm run build",
|
|
26
|
+
"build": "npm run clean; tsc; tsc -p tsconfig.esm.json; npm run copy",
|
|
27
|
+
"build:dev": "npm run clean; tsc --declarationMap; tsc -p tsconfig.esm.json; npm run copy",
|
|
28
|
+
"lint": "eslint . --fix",
|
|
29
|
+
"test": "jest",
|
|
30
|
+
"test:watch": "jest --watch"
|
|
31
|
+
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"@launchql/types": "^2.0.4",
|
|
34
|
+
"chalk": "^4.1.0"
|
|
35
|
+
}
|
|
36
|
+
}
|
package/sql/roles.sql
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
BEGIN;
|
|
2
|
+
DO $do$
|
|
3
|
+
BEGIN
|
|
4
|
+
IF NOT EXISTS (
|
|
5
|
+
SELECT
|
|
6
|
+
FROM
|
|
7
|
+
pg_catalog.pg_roles
|
|
8
|
+
WHERE
|
|
9
|
+
rolname = 'administrator') THEN
|
|
10
|
+
CREATE ROLE administrator;
|
|
11
|
+
END IF;
|
|
12
|
+
IF NOT EXISTS (
|
|
13
|
+
SELECT
|
|
14
|
+
FROM
|
|
15
|
+
pg_catalog.pg_roles
|
|
16
|
+
WHERE
|
|
17
|
+
rolname = 'anonymous') THEN
|
|
18
|
+
CREATE ROLE anonymous;
|
|
19
|
+
END IF;
|
|
20
|
+
IF NOT EXISTS (
|
|
21
|
+
SELECT
|
|
22
|
+
FROM
|
|
23
|
+
pg_catalog.pg_roles
|
|
24
|
+
WHERE
|
|
25
|
+
rolname = 'authenticated') THEN
|
|
26
|
+
CREATE ROLE authenticated;
|
|
27
|
+
END IF;
|
|
28
|
+
END
|
|
29
|
+
$do$;
|
|
30
|
+
ALTER USER administrator WITH NOCREATEDB;
|
|
31
|
+
ALTER USER administrator WITH NOCREATEROLE;
|
|
32
|
+
ALTER USER administrator WITH NOLOGIN;
|
|
33
|
+
ALTER USER administrator WITH NOREPLICATION;
|
|
34
|
+
ALTER USER administrator WITH BYPASSRLS;
|
|
35
|
+
ALTER USER anonymous WITH NOCREATEDB;
|
|
36
|
+
ALTER USER anonymous WITH NOCREATEROLE;
|
|
37
|
+
ALTER USER anonymous WITH NOLOGIN;
|
|
38
|
+
ALTER USER anonymous WITH NOREPLICATION;
|
|
39
|
+
ALTER USER anonymous WITH NOBYPASSRLS;
|
|
40
|
+
ALTER USER authenticated WITH NOCREATEDB;
|
|
41
|
+
ALTER USER authenticated WITH NOCREATEROLE;
|
|
42
|
+
ALTER USER authenticated WITH NOLOGIN;
|
|
43
|
+
ALTER USER authenticated WITH NOREPLICATION;
|
|
44
|
+
ALTER USER authenticated WITH NOBYPASSRLS;
|
|
45
|
+
GRANT anonymous TO administrator;
|
|
46
|
+
GRANT authenticated TO administrator;
|
|
47
|
+
COMMIT;
|
|
48
|
+
|
package/sql/test.sql
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
-- https://en.wikipedia.org/wiki/Role-based_access_control
|
|
2
|
+
BEGIN;
|
|
3
|
+
CREATE EXTENSION IF NOT EXISTS citext;
|
|
4
|
+
DROP SCHEMA IF EXISTS app_public CASCADE;
|
|
5
|
+
CREATE SCHEMA app_public;
|
|
6
|
+
CREATE TABLE app_public.users (
|
|
7
|
+
id serial PRIMARY KEY,
|
|
8
|
+
username citext,
|
|
9
|
+
UNIQUE (username),
|
|
10
|
+
CHECK (length(username) < 127)
|
|
11
|
+
);
|
|
12
|
+
CREATE TABLE app_public.roles (
|
|
13
|
+
id serial PRIMARY KEY,
|
|
14
|
+
org_id bigint NOT NULL REFERENCES app_public.users (id)
|
|
15
|
+
);
|
|
16
|
+
CREATE TABLE app_public.user_settings (
|
|
17
|
+
user_id bigint NOT NULL PRIMARY KEY REFERENCES app_public.users (id),
|
|
18
|
+
setting1 text,
|
|
19
|
+
UNIQUE (user_id)
|
|
20
|
+
);
|
|
21
|
+
CREATE TABLE app_public.permissions (
|
|
22
|
+
id serial PRIMARY KEY,
|
|
23
|
+
name citext
|
|
24
|
+
);
|
|
25
|
+
CREATE TABLE app_public.permission_assignment (
|
|
26
|
+
perm_id bigint NOT NULL REFERENCES app_public.permissions (id),
|
|
27
|
+
role_id bigint NOT NULL REFERENCES app_public.roles (id),
|
|
28
|
+
PRIMARY KEY (perm_id, role_id)
|
|
29
|
+
);
|
|
30
|
+
CREATE TABLE app_public.subject_assignment (
|
|
31
|
+
subj_id bigint NOT NULL REFERENCES app_public.users (id),
|
|
32
|
+
role_id bigint NOT NULL REFERENCES app_public.roles (id),
|
|
33
|
+
PRIMARY KEY (subj_id, role_id)
|
|
34
|
+
);
|
|
35
|
+
COMMIT;
|
|
36
|
+
|
package/src/admin.ts
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { execSync } from 'child_process';
|
|
2
|
+
import { PgConfig } from '@launchql/types';
|
|
3
|
+
import { existsSync } from 'fs';
|
|
4
|
+
import { streamSql as stream } from './stream';
|
|
5
|
+
|
|
6
|
+
export class DbAdmin {
|
|
7
|
+
constructor(
|
|
8
|
+
private config: PgConfig,
|
|
9
|
+
private verbose: boolean = false
|
|
10
|
+
) { }
|
|
11
|
+
|
|
12
|
+
private getEnv(): Record<string, string> {
|
|
13
|
+
return {
|
|
14
|
+
PGHOST: this.config.host,
|
|
15
|
+
PGPORT: String(this.config.port),
|
|
16
|
+
PGUSER: this.config.user,
|
|
17
|
+
PGPASSWORD: this.config.password,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
private run(command: string): void {
|
|
22
|
+
execSync(command, {
|
|
23
|
+
stdio: this.verbose ? 'inherit' : 'pipe',
|
|
24
|
+
env: {
|
|
25
|
+
...process.env,
|
|
26
|
+
...this.getEnv(),
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
private safeDropDb(name: string): void {
|
|
32
|
+
try {
|
|
33
|
+
this.run(`dropdb "${name}"`);
|
|
34
|
+
} catch (err) {
|
|
35
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
36
|
+
if (!message.includes('does not exist')) {
|
|
37
|
+
console.warn(`⚠️ Could not drop database ${name}: ${message}`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
drop(dbName?: string): void {
|
|
43
|
+
this.safeDropDb(dbName ?? this.config.database);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
dropTemplate(dbName: string): void {
|
|
47
|
+
this.run(`psql -c "UPDATE pg_database SET datistemplate='false' WHERE datname='${dbName}';"`);
|
|
48
|
+
this.drop(dbName);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
create(dbName?: string): void {
|
|
52
|
+
const db = dbName ?? this.config.database;
|
|
53
|
+
this.run(`createdb -U ${this.config.user} -h ${this.config.host} -p ${this.config.port} "${db}"`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
createFromTemplate(template: string, dbName?: string): void {
|
|
57
|
+
const db = dbName ?? this.config.database;
|
|
58
|
+
this.run(`createdb -U ${this.config.user} -h ${this.config.host} -p ${this.config.port} -e "${db}" -T "${template}"`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
installExtensions(extensions: string[] | string, dbName?: string): void {
|
|
62
|
+
const db = dbName ?? this.config.database;
|
|
63
|
+
const extList = typeof extensions === 'string' ? extensions.split(',') : extensions;
|
|
64
|
+
|
|
65
|
+
for (const extension of extList) {
|
|
66
|
+
this.run(`psql --dbname "${db}" -c 'CREATE EXTENSION IF NOT EXISTS "${extension}" CASCADE;'`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
connectionString(dbName?: string): string {
|
|
71
|
+
const { user, password, host, port } = this.config;
|
|
72
|
+
const db = dbName ?? this.config.database;
|
|
73
|
+
return `postgres://${user}:${password}@${host}:${port}/${db}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
createTemplateFromBase(base: string, template: string): void {
|
|
77
|
+
this.run(`createdb -T "${base}" "${template}"`);
|
|
78
|
+
this.run(`psql -c "UPDATE pg_database SET datistemplate = true WHERE datname = '${template}';"`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
cleanupTemplate(template: string): void {
|
|
82
|
+
try {
|
|
83
|
+
this.run(`psql -c "UPDATE pg_database SET datistemplate = false WHERE datname = '${template}'"`);
|
|
84
|
+
} catch { }
|
|
85
|
+
this.safeDropDb(template);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async createRole(role: string, password: string, dbName?: string): Promise<void> {
|
|
89
|
+
const db = dbName ?? this.config.database;
|
|
90
|
+
const sql = `CREATE ROLE ${role} WITH LOGIN PASSWORD '${password}';`;
|
|
91
|
+
await this.streamSql(sql, db);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async grantRole(role: string, user: string, dbName?: string): Promise<void> {
|
|
95
|
+
const db = dbName ?? this.config.database;
|
|
96
|
+
const sql = `GRANT ${role} TO ${user};`;
|
|
97
|
+
await this.streamSql(sql, db);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async grantConnect(role: string, dbName?: string): Promise<void> {
|
|
101
|
+
const db = dbName ?? this.config.database;
|
|
102
|
+
const sql = `GRANT CONNECT ON DATABASE "${db}" TO ${role};`;
|
|
103
|
+
await this.streamSql(sql, db);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async createUserRole(user: string, password: string, dbName: string): Promise<void> {
|
|
107
|
+
const sql = `
|
|
108
|
+
DO $$
|
|
109
|
+
BEGIN
|
|
110
|
+
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '${user}') THEN
|
|
111
|
+
CREATE ROLE ${user} LOGIN PASSWORD '${password}';
|
|
112
|
+
GRANT anonymous TO ${user};
|
|
113
|
+
GRANT authenticated TO ${user};
|
|
114
|
+
END IF;
|
|
115
|
+
END $$;
|
|
116
|
+
`.trim();
|
|
117
|
+
|
|
118
|
+
this.streamSql(sql, dbName);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
loadSql(file: string, dbName: string): void {
|
|
122
|
+
if (!existsSync(file)) {
|
|
123
|
+
throw new Error(`Missing SQL file: ${file}`);
|
|
124
|
+
}
|
|
125
|
+
this.run(`psql -f ${file} ${dbName}`);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async streamSql(sql: string, dbName: string): Promise<void> {
|
|
129
|
+
await stream({
|
|
130
|
+
...this.config,
|
|
131
|
+
database: dbName
|
|
132
|
+
}, sql);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
}
|
package/src/connect.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { randomUUID } from 'crypto';
|
|
2
|
+
import { getPgEnvOptions, PgConfig } from '@launchql/types';
|
|
3
|
+
import { PgTestConnector } from './manager';
|
|
4
|
+
import { DbAdmin } from './admin';
|
|
5
|
+
|
|
6
|
+
let manager: PgTestConnector;
|
|
7
|
+
export const getConnections = async () => {
|
|
8
|
+
const config: PgConfig = getPgEnvOptions({
|
|
9
|
+
database: `db-${randomUUID()}`
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
const app_user = 'app_user';
|
|
13
|
+
const app_password = 'app_password';
|
|
14
|
+
|
|
15
|
+
const admin = new DbAdmin(config);
|
|
16
|
+
|
|
17
|
+
// Create the test database
|
|
18
|
+
admin.create(config.database);
|
|
19
|
+
|
|
20
|
+
// Main admin client (optional unless needed elsewhere)
|
|
21
|
+
manager = PgTestConnector.getInstance();
|
|
22
|
+
const db = manager.getClient(config);
|
|
23
|
+
|
|
24
|
+
// Set up test role
|
|
25
|
+
admin.createUserRole(app_user, app_password, config.database);
|
|
26
|
+
admin.grantConnect(app_user, config.database);
|
|
27
|
+
|
|
28
|
+
// App user connection
|
|
29
|
+
const conn = manager.getClient({
|
|
30
|
+
...config,
|
|
31
|
+
user: app_user,
|
|
32
|
+
password: app_password
|
|
33
|
+
})
|
|
34
|
+
// const conn = await getTestConnection(config.database, app_user, app_password);
|
|
35
|
+
conn.setContext({ role: 'anonymous' });
|
|
36
|
+
|
|
37
|
+
const teardown = async () => {
|
|
38
|
+
await manager.closeAll();
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
return { db, conn, teardown };
|
|
42
|
+
};
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { PgTestClient } from './test-client';
|
|
2
|
+
import { PgTestConnector } from './manager';
|
|
3
|
+
import { randomUUID } from 'crypto';
|
|
4
|
+
import { getPgEnvOptions, PgConfig } from '@launchql/types';
|
|
5
|
+
|
|
6
|
+
export function connect(config: PgConfig): PgTestClient {
|
|
7
|
+
const manager = PgTestConnector.getInstance();
|
|
8
|
+
return manager.getClient(config);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function close(client: PgTestClient): void {
|
|
12
|
+
client.close();
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const manager = PgTestConnector.getInstance();
|
|
16
|
+
|
|
17
|
+
export const Connection = {
|
|
18
|
+
connect(config: Partial<PgConfig>): PgTestClient {
|
|
19
|
+
const creds = getPgEnvOptions(config);
|
|
20
|
+
return manager.getClient(creds);
|
|
21
|
+
},
|
|
22
|
+
|
|
23
|
+
close(client: PgTestClient): void {
|
|
24
|
+
client.close();
|
|
25
|
+
},
|
|
26
|
+
|
|
27
|
+
closeAll(): Promise<void> {
|
|
28
|
+
return manager.closeAll();
|
|
29
|
+
},
|
|
30
|
+
|
|
31
|
+
getManager(): PgTestConnector {
|
|
32
|
+
return manager;
|
|
33
|
+
}
|
|
34
|
+
};
|
package/src/manager.ts
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { Pool } from 'pg';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import { DbAdmin } from './admin';
|
|
4
|
+
import { PgConfig } from '@launchql/types';
|
|
5
|
+
import { PgTestClient } from './test-client';
|
|
6
|
+
|
|
7
|
+
const SYS_EVENTS = ['SIGTERM'];
|
|
8
|
+
|
|
9
|
+
const end = (pool: Pool) => {
|
|
10
|
+
try {
|
|
11
|
+
if ((pool as any).ended || (pool as any).ending) {
|
|
12
|
+
console.warn(chalk.yellow('⚠️ pg pool already ended or ending'));
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
pool.end();
|
|
16
|
+
} catch (err) {
|
|
17
|
+
console.error(chalk.red('❌ pg pool termination error:'), err);
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export class PgTestConnector {
|
|
22
|
+
private static instance: PgTestConnector;
|
|
23
|
+
|
|
24
|
+
private readonly clients = new Set<PgTestClient>();
|
|
25
|
+
private readonly pgPools = new Map<string, Pool>();
|
|
26
|
+
private readonly seenDbConfigs = new Map<string, PgConfig>();
|
|
27
|
+
|
|
28
|
+
private verbose = false;
|
|
29
|
+
|
|
30
|
+
private constructor(verbose = false) {
|
|
31
|
+
this.verbose = verbose;
|
|
32
|
+
|
|
33
|
+
SYS_EVENTS.forEach((event) => {
|
|
34
|
+
process.on(event, () => {
|
|
35
|
+
this.log(chalk.magenta(`⏹ Received ${event}, closing all connections...`));
|
|
36
|
+
this.closeAll();
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
static getInstance(verbose = false): PgTestConnector {
|
|
42
|
+
if (!PgTestConnector.instance) {
|
|
43
|
+
PgTestConnector.instance = new PgTestConnector(verbose);
|
|
44
|
+
}
|
|
45
|
+
return PgTestConnector.instance;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
private log(...args: any[]) {
|
|
49
|
+
if (this.verbose) console.log(...args);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
private poolKey(config: PgConfig): string {
|
|
53
|
+
return `${config.user}@${config.host}:${config.port}/${config.database}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
private dbKey(config: PgConfig): string {
|
|
57
|
+
return `${config.host}:${config.port}/${config.database}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
getAdmin(config: PgConfig): DbAdmin {
|
|
61
|
+
return new DbAdmin(config, this.verbose);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
getPool(config: PgConfig): Pool {
|
|
65
|
+
const key = this.poolKey(config);
|
|
66
|
+
if (!this.pgPools.has(key)) {
|
|
67
|
+
const pool = new Pool(config);
|
|
68
|
+
this.pgPools.set(key, pool);
|
|
69
|
+
this.log(chalk.blue(`📘 Created new pg pool: ${chalk.white(key)}`));
|
|
70
|
+
}
|
|
71
|
+
return this.pgPools.get(key)!;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
getClient(config: PgConfig): PgTestClient {
|
|
75
|
+
const client = new PgTestClient(config);
|
|
76
|
+
this.clients.add(client);
|
|
77
|
+
|
|
78
|
+
const key = this.dbKey(config);
|
|
79
|
+
this.seenDbConfigs.set(key, config);
|
|
80
|
+
|
|
81
|
+
this.log(chalk.green(`🔌 New PgTestClient connected to ${config.database}`));
|
|
82
|
+
return client;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async closeAll(): Promise<void> {
|
|
86
|
+
this.log(chalk.cyan('\n🧹 Closing all PgTestClients...'));
|
|
87
|
+
await Promise.all(
|
|
88
|
+
Array.from(this.clients).map(async (client) => {
|
|
89
|
+
try {
|
|
90
|
+
await client.close();
|
|
91
|
+
this.log(chalk.green(`✅ Closed client for ${client.config.database}`));
|
|
92
|
+
} catch (err) {
|
|
93
|
+
console.warn(chalk.red(`❌ Error closing PgTestClient for ${client.config.database}:`), err);
|
|
94
|
+
}
|
|
95
|
+
})
|
|
96
|
+
);
|
|
97
|
+
this.clients.clear();
|
|
98
|
+
|
|
99
|
+
this.log(chalk.cyan('\n🧯 Disposing pg pools...'));
|
|
100
|
+
for (const [key, pool] of this.pgPools.entries()) {
|
|
101
|
+
this.log(chalk.gray(`🧯 Disposing pg pool [${key}]`));
|
|
102
|
+
end(pool);
|
|
103
|
+
}
|
|
104
|
+
this.pgPools.clear();
|
|
105
|
+
|
|
106
|
+
this.log(chalk.cyan('\n🗑️ Dropping seen databases...'));
|
|
107
|
+
await Promise.all(
|
|
108
|
+
Array.from(this.seenDbConfigs.values()).map(async (config) => {
|
|
109
|
+
try {
|
|
110
|
+
// somehow an "admin" db had app_user creds?
|
|
111
|
+
const admin = new DbAdmin({...config, user: 'postgres', password: 'password'}, this.verbose);
|
|
112
|
+
// console.log(config);
|
|
113
|
+
admin.drop();
|
|
114
|
+
this.log(chalk.yellow(`🧨 Dropped database: ${chalk.white(config.database)}`));
|
|
115
|
+
} catch (err) {
|
|
116
|
+
console.warn(chalk.red(`❌ Failed to drop database ${config.database}:`), err);
|
|
117
|
+
}
|
|
118
|
+
})
|
|
119
|
+
);
|
|
120
|
+
this.seenDbConfigs.clear();
|
|
121
|
+
|
|
122
|
+
this.log(chalk.green('\n✅ All PgTestClients closed, pools disposed, databases dropped.'));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
close(): void {
|
|
126
|
+
this.closeAll();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
drop(config: PgConfig): void {
|
|
130
|
+
const key = this.dbKey(config);
|
|
131
|
+
const admin = new DbAdmin(config, this.verbose);
|
|
132
|
+
admin.drop();
|
|
133
|
+
this.log(chalk.red(`🧨 Dropped database: ${chalk.white(config.database)}`));
|
|
134
|
+
this.seenDbConfigs.delete(key);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
kill(client: PgTestClient): void {
|
|
138
|
+
client.close();
|
|
139
|
+
this.drop(client.config);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
}
|
package/src/stream.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { spawn } from 'child_process';
|
|
2
|
+
import { Readable } from 'stream';
|
|
3
|
+
import { env } from 'process';
|
|
4
|
+
import { PgConfig } from '@launchql/types';
|
|
5
|
+
|
|
6
|
+
function setArgs(config: PgConfig): string[] {
|
|
7
|
+
const args = [
|
|
8
|
+
'-U', config.user,
|
|
9
|
+
'-h', config.host,
|
|
10
|
+
'-d', config.database
|
|
11
|
+
];
|
|
12
|
+
if (config.port) {
|
|
13
|
+
args.push('-p', String(config.port));
|
|
14
|
+
}
|
|
15
|
+
return args;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Converts a string to a readable stream (replaces streamify-string)
|
|
19
|
+
function stringToStream(text: string): Readable {
|
|
20
|
+
const stream = new Readable({
|
|
21
|
+
read() {
|
|
22
|
+
this.push(text);
|
|
23
|
+
this.push(null);
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
return stream;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function streamSql(config: PgConfig, sql: string): Promise<void> {
|
|
30
|
+
const args = setArgs(config);
|
|
31
|
+
|
|
32
|
+
return new Promise<void>((resolve, reject) => {
|
|
33
|
+
const sqlStream = stringToStream(sql);
|
|
34
|
+
|
|
35
|
+
// TODO set env vars!
|
|
36
|
+
const proc = spawn('psql', args, {
|
|
37
|
+
env: {
|
|
38
|
+
...env,
|
|
39
|
+
PGUSER: config.user,
|
|
40
|
+
PGHOST: config.host,
|
|
41
|
+
PGDATABASE: config.database,
|
|
42
|
+
PGPASSWORD: config.password,
|
|
43
|
+
// PGPORT: config.port
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
sqlStream.pipe(proc.stdin);
|
|
48
|
+
|
|
49
|
+
proc.on('close', (code) => {
|
|
50
|
+
resolve();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
proc.on('error', (error) => {
|
|
54
|
+
reject(error);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
proc.stderr.on('data', (data: Buffer) => {
|
|
58
|
+
reject(new Error(data.toString()));
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { Client, QueryResult } from 'pg';
|
|
2
|
+
import { PgConfig } from '@launchql/types';
|
|
3
|
+
|
|
4
|
+
export class PgTestClient {
|
|
5
|
+
public config: PgConfig;
|
|
6
|
+
private client: Client;
|
|
7
|
+
private ctxStmts: string = '';
|
|
8
|
+
private _ended: boolean = false;
|
|
9
|
+
|
|
10
|
+
constructor(config: PgConfig) {
|
|
11
|
+
this.config = config;
|
|
12
|
+
this.client = new Client({
|
|
13
|
+
host: this.config.host,
|
|
14
|
+
port: this.config.port,
|
|
15
|
+
database: this.config.database,
|
|
16
|
+
user: this.config.user,
|
|
17
|
+
password: this.config.password
|
|
18
|
+
});
|
|
19
|
+
this.client.connect();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
close(): void {
|
|
23
|
+
if (!this._ended) {
|
|
24
|
+
this._ended = true;
|
|
25
|
+
this.client.end();
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async begin(): Promise<void> {
|
|
30
|
+
await this.client.query('BEGIN;');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async savepoint(name: string = 'lqlsavepoint'): Promise<void> {
|
|
34
|
+
await this.client.query(`SAVEPOINT "${name}";`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async rollback(name: string = 'lqlsavepoint'): Promise<void> {
|
|
38
|
+
await this.client.query(`ROLLBACK TO SAVEPOINT "${name}";`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async commit(): Promise<void> {
|
|
42
|
+
await this.client.query('COMMIT;');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async beforeEach(): Promise<void> {
|
|
46
|
+
await this.begin();
|
|
47
|
+
await this.savepoint();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async afterEach(): Promise<void> {
|
|
51
|
+
await this.rollback();
|
|
52
|
+
await this.commit();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
setContext(ctx: Record<string, string | null>): void {
|
|
56
|
+
this.ctxStmts = Object.entries(ctx)
|
|
57
|
+
.map(([key, val]) =>
|
|
58
|
+
val === null
|
|
59
|
+
? `SELECT set_config('${key}', NULL, true);`
|
|
60
|
+
: `SELECT set_config('${key}', '${val}', true);`
|
|
61
|
+
)
|
|
62
|
+
.join('\n');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
private async runCtxQuery<T = any>(query: string, values?: any[]): Promise<QueryResult<T>> {
|
|
66
|
+
if (this.ctxStmts) {
|
|
67
|
+
await this.client.query(this.ctxStmts);
|
|
68
|
+
}
|
|
69
|
+
const result = await this.client.query<T>(query, values);
|
|
70
|
+
return result;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async any<T = any>(query: string, values?: any[]): Promise<T[]> {
|
|
74
|
+
const result = await this.runCtxQuery(query, values);
|
|
75
|
+
return result.rows;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async one<T = any>(query: string, values?: any[]): Promise<T> {
|
|
79
|
+
const rows = await this.any<T>(query, values);
|
|
80
|
+
if (rows.length !== 1) {
|
|
81
|
+
throw new Error('Expected exactly one result');
|
|
82
|
+
}
|
|
83
|
+
return rows[0];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async oneOrNone<T = any>(query: string, values?: any[]): Promise<T | null> {
|
|
87
|
+
const rows = await this.any<T>(query, values);
|
|
88
|
+
return rows[0] || null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async many<T = any>(query: string, values?: any[]): Promise<T[]> {
|
|
92
|
+
const rows = await this.any<T>(query, values);
|
|
93
|
+
if (rows.length === 0) throw new Error('Expected many rows, got none');
|
|
94
|
+
return rows;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async manyOrNone<T = any>(query: string, values?: any[]): Promise<T[]> {
|
|
98
|
+
return this.any<T>(query, values);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async none(query: string, values?: any[]): Promise<void> {
|
|
102
|
+
await this.runCtxQuery(query, values);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async result(query: string, values?: any[]): Promise<import('pg').QueryResult> {
|
|
106
|
+
return this.runCtxQuery(query, values);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async query<T = any>(query: string, values?: any[]): Promise<QueryResult<T>> {
|
|
110
|
+
return this.client.query<T>(query, values);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
}
|
package/src/utils.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { Client, Pool } from 'pg';
|
|
2
|
+
// import { createdb, dropdb, templatedb, installExt, grantConnect } from './db';
|
|
3
|
+
import { connect, close } from './legacy-connect';
|
|
4
|
+
import { getPgEnvOptions, PgConfig } from '@launchql/types';
|
|
5
|
+
import { getEnvOptions } from '@launchql/types';
|
|
6
|
+
import { randomUUID } from 'crypto';
|
|
7
|
+
import { PgTestClient } from './test-client';
|
|
8
|
+
import { DbAdmin } from './admin';
|
|
9
|
+
|
|
10
|
+
export interface TestOptions {
|
|
11
|
+
hot?: boolean;
|
|
12
|
+
template?: string;
|
|
13
|
+
prefix?: string;
|
|
14
|
+
extensions?: string[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function getOpts(configOpts: TestOptions = {}): TestOptions {
|
|
18
|
+
return {
|
|
19
|
+
template: configOpts.template,
|
|
20
|
+
prefix: configOpts.prefix || 'testing-db',
|
|
21
|
+
extensions: configOpts.extensions || [],
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function getConnection(configOpts: TestOptions, database?: string): PgTestClient {
|
|
26
|
+
const opts = getOpts(configOpts);
|
|
27
|
+
const dbName = database || `${opts.prefix}-${Date.now()}`;
|
|
28
|
+
|
|
29
|
+
const config = getPgEnvOptions({
|
|
30
|
+
database: dbName
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const admin = new DbAdmin(config);
|
|
34
|
+
|
|
35
|
+
if (process.env.TEST_DB) {
|
|
36
|
+
config.database = process.env.TEST_DB;
|
|
37
|
+
} else if (opts.hot) {
|
|
38
|
+
admin.create(config.database);
|
|
39
|
+
admin.installExtensions(opts.extensions);
|
|
40
|
+
} else if (opts.template) {
|
|
41
|
+
admin.createFromTemplate(opts.template, config.database);
|
|
42
|
+
} else {
|
|
43
|
+
admin.create(config.database);
|
|
44
|
+
admin.installExtensions(opts.extensions);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return connect(config);
|
|
48
|
+
}
|