@travetto/model-postgres 8.0.0-alpha.9 → 8.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 +33 -28
- package/__index__.ts +3 -2
- package/package.json +20 -20
- package/src/config.ts +50 -0
- package/src/connection.ts +78 -53
- package/src/dialect.ts +290 -137
- package/src/service.ts +28 -0
- package/support/service.postgresql.ts +2 -2
package/README.md
CHANGED
|
@@ -13,78 +13,83 @@ npm install @travetto/model-postgres
|
|
|
13
13
|
yarn add @travetto/model-postgres
|
|
14
14
|
```
|
|
15
15
|
|
|
16
|
-
This module provides a [Postgres](https://postgresql.org)-based implementation for the [Data Modeling Support](https://github.com/travetto/travetto/tree/main/module/model#readme "Datastore abstraction for core operations.") module.
|
|
16
|
+
This module provides a [Postgres](https://postgresql.org)-based implementation for the [Data Modeling Support](https://github.com/travetto/travetto/tree/main/module/model#readme "Datastore abstraction for core operations.") module. This source allows the [Data Modeling Support](https://github.com/travetto/travetto/tree/main/module/model#readme "Datastore abstraction for core operations.") module to read, write and query against [SQL](https://en.wikipedia.org/wiki/SQL) databases. In development mode, the [PostgresModelService](https://github.com/travetto/travetto/tree/main/module/model-postgres/src/service.ts#L12) will also modify the database schema in real time to minimize impact to development.
|
|
17
17
|
|
|
18
|
-
The schema generated will not generally map to existing tables as it is attempting to produce a document store like experience on top of a [SQL](https://en.wikipedia.org/wiki/SQL) database.
|
|
18
|
+
The schema generated will not generally map to existing tables as it is attempting to produce a document store like experience on top of a [SQL](https://en.wikipedia.org/wiki/SQL) database. Every table generated maps to a model:
|
|
19
|
+
* Simple scalar fields map directly to individual native PostgreSQL columns (e.g., `VARCHAR`, `INTEGER`, `TIMESTAMP`).
|
|
20
|
+
* Simple scalar arrays (such as `string[]`, `number[]`, or `boolean[]`) map directly to PostgreSQL native array columns (e.g., `VARCHAR[]`, `INTEGER[]`).
|
|
21
|
+
* Complex fields and arrays of sub-schema objects map to native `JSONB` columns.
|
|
19
22
|
|
|
20
23
|
Supported features:
|
|
21
|
-
* [
|
|
22
|
-
* [
|
|
24
|
+
* [Bulk](https://github.com/travetto/travetto/tree/main/module/model/src/types/bulk.ts#L60)
|
|
25
|
+
* [CRUD](https://github.com/travetto/travetto/tree/main/module/model/src/types/crud.ts#L10)
|
|
26
|
+
* [Expiry](https://github.com/travetto/travetto/tree/main/module/model/src/types/expiry.ts#L10)
|
|
27
|
+
* [Indexed](https://github.com/travetto/travetto/tree/main/module/model-indexed/src/types/service.ts#L21)
|
|
23
28
|
* [Query Crud](https://github.com/travetto/travetto/tree/main/module/model-query/src/types/crud.ts#L11)
|
|
24
29
|
* [Facet](https://github.com/travetto/travetto/tree/main/module/model-query/src/types/facet.ts#L14)
|
|
25
|
-
* [Query](https://github.com/travetto/travetto/tree/main/module/model-query/src/types/query.ts#L10)
|
|
26
30
|
* [Suggest](https://github.com/travetto/travetto/tree/main/module/model-query/src/types/suggest.ts#L12)
|
|
31
|
+
* [Query](https://github.com/travetto/travetto/tree/main/module/model-query/src/types/query.ts#L10)
|
|
27
32
|
|
|
28
33
|
Out of the box, by installing the module, everything should be wired up by default.If you need to customize any aspect of the source or config, you can override and register it with the [Dependency Injection](https://github.com/travetto/travetto/tree/main/module/di#readme "Dependency registration/management and injection support.") module.
|
|
29
34
|
|
|
30
35
|
**Code: Wiring up a custom Model Source**
|
|
31
36
|
```typescript
|
|
32
|
-
import type { AsyncContext } from '@travetto/context';
|
|
33
37
|
import { InjectableFactory } from '@travetto/di';
|
|
34
|
-
|
|
35
|
-
import { SQLModelService, type SQLModelConfig } from '@travetto/model-sql';
|
|
36
|
-
import { PostgreSQLDialect } from '@travetto/model-postgres';
|
|
38
|
+
import { type PostgresConnection, PostgresModelService } from '@travetto/model-postgres';
|
|
37
39
|
|
|
38
40
|
export class Init {
|
|
39
41
|
@InjectableFactory({ primary: true })
|
|
40
|
-
static getModelService(
|
|
41
|
-
return new
|
|
42
|
+
static getModelService(connection: PostgresConnection) {
|
|
43
|
+
return new PostgresModelService(connection);
|
|
42
44
|
}
|
|
43
45
|
}
|
|
44
46
|
```
|
|
45
47
|
|
|
46
|
-
where the [
|
|
48
|
+
where the [PostgresModelConfig](https://github.com/travetto/travetto/tree/main/module/model-postgres/src/config.ts#L10) is defined by:
|
|
47
49
|
|
|
48
|
-
**Code: Structure of
|
|
50
|
+
**Code: Structure of PostgresModelConfig**
|
|
49
51
|
```typescript
|
|
50
|
-
@Config('model.
|
|
51
|
-
export class
|
|
52
|
+
@Config('model.postgres')
|
|
53
|
+
export class PostgresModelConfig {
|
|
52
54
|
/**
|
|
53
|
-
*
|
|
55
|
+
* Database host to connect to
|
|
54
56
|
*/
|
|
55
57
|
host = '127.0.0.1';
|
|
58
|
+
|
|
56
59
|
/**
|
|
57
|
-
*
|
|
60
|
+
* Database port to connect to
|
|
58
61
|
*/
|
|
59
62
|
port = 0;
|
|
63
|
+
|
|
60
64
|
/**
|
|
61
|
-
*
|
|
65
|
+
* Database username
|
|
62
66
|
*/
|
|
63
67
|
user = Runtime.production ? '' : 'travetto';
|
|
68
|
+
|
|
64
69
|
/**
|
|
65
|
-
*
|
|
70
|
+
* Database password
|
|
66
71
|
*/
|
|
67
72
|
password = Runtime.production ? '' : 'travetto';
|
|
73
|
+
|
|
68
74
|
/**
|
|
69
|
-
*
|
|
75
|
+
* Namespace/schema prefix for table names
|
|
70
76
|
*/
|
|
71
77
|
namespace = '';
|
|
78
|
+
|
|
72
79
|
/**
|
|
73
80
|
* Database name
|
|
74
81
|
*/
|
|
75
82
|
database = 'app';
|
|
83
|
+
|
|
76
84
|
/**
|
|
77
|
-
* Allow storage
|
|
78
|
-
*/
|
|
79
|
-
modifyStorage?: boolean;
|
|
80
|
-
/**
|
|
81
|
-
* Db version
|
|
85
|
+
* Allow storage modifications (like table auto-creation and schema updates) at runtime
|
|
82
86
|
*/
|
|
83
|
-
|
|
87
|
+
modifyStorage = !Runtime.production;
|
|
88
|
+
|
|
84
89
|
/**
|
|
85
|
-
*
|
|
90
|
+
* Client specific overrides
|
|
86
91
|
*/
|
|
87
|
-
options
|
|
92
|
+
options?: PG.ClientConfig;
|
|
88
93
|
}
|
|
89
94
|
```
|
|
90
95
|
|
package/__index__.ts
CHANGED
|
@@ -1,2 +1,3 @@
|
|
|
1
|
-
export * from './src/
|
|
2
|
-
export * from './src/connection.ts';
|
|
1
|
+
export * from './src/config.ts';
|
|
2
|
+
export * from './src/connection.ts';
|
|
3
|
+
export * from './src/service.ts';
|
package/package.json
CHANGED
|
@@ -1,43 +1,46 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@travetto/model-postgres",
|
|
3
|
-
"version": "8.0.
|
|
4
|
-
"type": "module",
|
|
3
|
+
"version": "8.0.1",
|
|
5
4
|
"description": "PostgreSQL backing for the travetto model module, with real-time modeling support for SQL schemas.",
|
|
6
5
|
"keywords": [
|
|
7
|
-
"sql",
|
|
8
6
|
"data-modeling",
|
|
9
|
-
"real-time",
|
|
10
7
|
"model",
|
|
8
|
+
"real-time",
|
|
9
|
+
"sql",
|
|
11
10
|
"travetto",
|
|
12
11
|
"typescript"
|
|
13
12
|
],
|
|
14
13
|
"homepage": "https://travetto.io",
|
|
15
14
|
"license": "MIT",
|
|
16
15
|
"author": {
|
|
17
|
-
"
|
|
18
|
-
"
|
|
16
|
+
"name": "Travetto Framework",
|
|
17
|
+
"email": "travetto.framework@gmail.com"
|
|
18
|
+
},
|
|
19
|
+
"repository": {
|
|
20
|
+
"url": "git+https://github.com/travetto/travetto.git",
|
|
21
|
+
"directory": "module/model-postgres"
|
|
19
22
|
},
|
|
20
23
|
"files": [
|
|
21
24
|
"__index__.ts",
|
|
22
25
|
"src",
|
|
23
26
|
"support"
|
|
24
27
|
],
|
|
28
|
+
"type": "module",
|
|
25
29
|
"main": "__index__.ts",
|
|
26
|
-
"
|
|
27
|
-
"
|
|
28
|
-
"directory": "module/model-postgres"
|
|
30
|
+
"publishConfig": {
|
|
31
|
+
"access": "public"
|
|
29
32
|
},
|
|
30
33
|
"dependencies": {
|
|
31
|
-
"@travetto/config": "^8.0.
|
|
32
|
-
"@travetto/context": "^8.0.
|
|
33
|
-
"@travetto/model": "^8.0.
|
|
34
|
-
"@travetto/model-query": "^8.0.
|
|
35
|
-
"@travetto/model-sql": "^8.0.
|
|
36
|
-
"@types/pg": "^8.
|
|
37
|
-
"pg": "^8.
|
|
34
|
+
"@travetto/config": "^8.0.1",
|
|
35
|
+
"@travetto/context": "^8.0.1",
|
|
36
|
+
"@travetto/model": "^8.0.1",
|
|
37
|
+
"@travetto/model-query": "^8.0.1",
|
|
38
|
+
"@travetto/model-sql": "^8.0.1",
|
|
39
|
+
"@types/pg": "^8.23.1",
|
|
40
|
+
"pg": "^8.23.0"
|
|
38
41
|
},
|
|
39
42
|
"peerDependencies": {
|
|
40
|
-
"@travetto/cli": "^8.0.
|
|
43
|
+
"@travetto/cli": "^8.0.1"
|
|
41
44
|
},
|
|
42
45
|
"peerDependenciesMeta": {
|
|
43
46
|
"@travetto/cli": {
|
|
@@ -46,8 +49,5 @@
|
|
|
46
49
|
},
|
|
47
50
|
"travetto": {
|
|
48
51
|
"displayName": "PostgreSQL Model Service"
|
|
49
|
-
},
|
|
50
|
-
"publishConfig": {
|
|
51
|
-
"access": "public"
|
|
52
52
|
}
|
|
53
53
|
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type PG from 'pg';
|
|
2
|
+
|
|
3
|
+
import { Config } from '@travetto/config';
|
|
4
|
+
import { Runtime } from '@travetto/runtime';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* PostgreSQL Model Configuration
|
|
8
|
+
*/
|
|
9
|
+
@Config('model.postgres')
|
|
10
|
+
export class PostgresModelConfig {
|
|
11
|
+
/**
|
|
12
|
+
* Database host to connect to
|
|
13
|
+
*/
|
|
14
|
+
host = '127.0.0.1';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Database port to connect to
|
|
18
|
+
*/
|
|
19
|
+
port = 0;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Database username
|
|
23
|
+
*/
|
|
24
|
+
user = Runtime.production ? '' : 'travetto';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Database password
|
|
28
|
+
*/
|
|
29
|
+
password = Runtime.production ? '' : 'travetto';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Namespace/schema prefix for table names
|
|
33
|
+
*/
|
|
34
|
+
namespace = '';
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Database name
|
|
38
|
+
*/
|
|
39
|
+
database = 'app';
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Allow storage modifications (like table auto-creation and schema updates) at runtime
|
|
43
|
+
*/
|
|
44
|
+
modifyStorage = !Runtime.production;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Client specific overrides
|
|
48
|
+
*/
|
|
49
|
+
options?: PG.ClientConfig;
|
|
50
|
+
}
|
package/src/connection.ts
CHANGED
|
@@ -1,80 +1,105 @@
|
|
|
1
|
-
import { type Pool, type PoolClient, default as pg } from 'pg';
|
|
1
|
+
import { type DatabaseError, type Pool, type PoolClient, default as pg } from 'pg';
|
|
2
2
|
|
|
3
|
+
import type { AsyncContext } from '@travetto/context';
|
|
4
|
+
import { Injectable } from '@travetto/di';
|
|
5
|
+
import { ExistsError, UniqueError } from '@travetto/model';
|
|
6
|
+
import { SQLConnection } from '@travetto/model-sql';
|
|
3
7
|
import { castTo, ShutdownManager } from '@travetto/runtime';
|
|
4
|
-
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
8
|
+
|
|
9
|
+
import type { PostgresModelConfig } from './config.ts';
|
|
10
|
+
import { PostgresDialect } from './dialect.ts';
|
|
11
|
+
|
|
12
|
+
function isPgDatabaseError(error: unknown): error is DatabaseError {
|
|
13
|
+
return !!error && typeof error === 'object' && 'code' in error;
|
|
14
|
+
}
|
|
7
15
|
|
|
8
16
|
/**
|
|
9
|
-
*
|
|
17
|
+
* PostgreSQL connection manager
|
|
10
18
|
*/
|
|
11
|
-
|
|
19
|
+
@Injectable()
|
|
20
|
+
export class PostgresConnection extends SQLConnection<PoolClient> {
|
|
21
|
+
pool: Pool;
|
|
12
22
|
|
|
13
|
-
|
|
14
|
-
|
|
23
|
+
readonly dialect = new PostgresDialect();
|
|
24
|
+
readonly config: PostgresModelConfig;
|
|
15
25
|
|
|
16
|
-
constructor(
|
|
17
|
-
context: AsyncContext,
|
|
18
|
-
config: SQLModelConfig
|
|
19
|
-
) {
|
|
26
|
+
constructor(context: AsyncContext, config: PostgresModelConfig) {
|
|
20
27
|
super(context);
|
|
21
|
-
this
|
|
28
|
+
this.config = config;
|
|
22
29
|
}
|
|
23
30
|
|
|
24
31
|
/**
|
|
25
|
-
* Initializes
|
|
32
|
+
* Initializes the pool and creates the pgcrypto extension
|
|
26
33
|
*/
|
|
27
|
-
@WithAsyncContext()
|
|
28
34
|
async init(): Promise<void> {
|
|
29
|
-
this
|
|
30
|
-
user: this
|
|
31
|
-
password: this
|
|
32
|
-
database: this
|
|
33
|
-
host: this
|
|
34
|
-
port: this
|
|
35
|
+
this.pool = new pg.Pool({
|
|
36
|
+
user: this.config.user,
|
|
37
|
+
password: this.config.password,
|
|
38
|
+
database: this.config.database,
|
|
39
|
+
host: this.config.host,
|
|
40
|
+
port: this.config.port,
|
|
35
41
|
...castTo({
|
|
36
|
-
parseInputDatesAsUTC: true
|
|
42
|
+
parseInputDatesAsUTC: true
|
|
37
43
|
}),
|
|
38
|
-
...
|
|
44
|
+
...this.config.options
|
|
39
45
|
});
|
|
40
46
|
|
|
41
|
-
await this.runWithActive(() =>
|
|
42
|
-
this.runWithTransaction('required', () =>
|
|
43
|
-
this.execute(this.active!, 'CREATE EXTENSION IF NOT EXISTS pgcrypto;').catch(error => {
|
|
44
|
-
if (!(error instanceof Error && error.message.includes('already exists'))) {
|
|
45
|
-
throw error;
|
|
46
|
-
}
|
|
47
|
-
})
|
|
48
|
-
)
|
|
49
|
-
);
|
|
50
|
-
|
|
51
|
-
// Close postgres
|
|
52
|
-
ShutdownManager.signal.addEventListener('abort', () => this.#pool.end());
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
async execute<T = unknown>(pool: PoolClient, query: string, values?: unknown[]): Promise<{ count: number, records: T[] }> {
|
|
56
|
-
console.debug('Executing query', { query });
|
|
57
47
|
try {
|
|
58
|
-
|
|
59
|
-
const records: T[] = [...out.rows].map(value => ({ ...value }));
|
|
60
|
-
return { count: out.rowCount!, records };
|
|
48
|
+
await this.execute('CREATE EXTENSION IF NOT EXISTS pgcrypto;');
|
|
61
49
|
} catch (error) {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
// Index already exists
|
|
65
|
-
case '42P07': throw new ExistsError('index', query);
|
|
66
|
-
// Unique violation
|
|
67
|
-
case '23505': throw new ExistsError('query', query);
|
|
68
|
-
default: throw error;
|
|
50
|
+
if (!(error instanceof Error && error.message.includes('already exists'))) {
|
|
51
|
+
throw error;
|
|
69
52
|
}
|
|
70
53
|
}
|
|
54
|
+
|
|
55
|
+
ShutdownManager.signal.addEventListener('abort', () => this.pool.end());
|
|
71
56
|
}
|
|
72
57
|
|
|
58
|
+
/**
|
|
59
|
+
* Acquires a client from the pool
|
|
60
|
+
*/
|
|
73
61
|
acquire(): Promise<PoolClient> {
|
|
74
|
-
return this
|
|
62
|
+
return this.pool.connect();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Releases a client back to the pool
|
|
67
|
+
*/
|
|
68
|
+
release(connection: PoolClient): void {
|
|
69
|
+
connection.release();
|
|
75
70
|
}
|
|
76
71
|
|
|
77
|
-
|
|
78
|
-
|
|
72
|
+
/**
|
|
73
|
+
* Executes a query on the active client or pool directly
|
|
74
|
+
*/
|
|
75
|
+
async execute<Type = unknown>(query: string, values?: unknown[]): Promise<{ count: number; records: Type[] }> {
|
|
76
|
+
console.debug('Executing PostgreSQL query', { query, values });
|
|
77
|
+
|
|
78
|
+
// Handle dynamically built SAVEPOINT names that cannot be parameterized in Postgres
|
|
79
|
+
if (query.includes('SAVEPOINT') || query.includes('ROLLBACK TO') || query.includes('RELEASE SAVEPOINT')) {
|
|
80
|
+
if (values && values.length > 0) {
|
|
81
|
+
query = query.replace('$1', `"${values[0]}"`);
|
|
82
|
+
values = [];
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const client = this.active ?? this.pool;
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
const result = await client.query(query, values);
|
|
90
|
+
const records: Type[] = [...result.rows].map(row => ({ ...row }));
|
|
91
|
+
return { count: result.rowCount ?? 0, records };
|
|
92
|
+
} catch (error) {
|
|
93
|
+
if (isPgDatabaseError(error)) {
|
|
94
|
+
if (error.code === '23505') {
|
|
95
|
+
const constraint = error.constraint ?? (error.detail ? 'index' : 'query');
|
|
96
|
+
const detail = error.detail ?? query;
|
|
97
|
+
throw new UniqueError('index', constraint, { detail, query });
|
|
98
|
+
} else if (error.code === '42P07') {
|
|
99
|
+
throw new ExistsError('index', query);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
throw error;
|
|
103
|
+
}
|
|
79
104
|
}
|
|
80
|
-
}
|
|
105
|
+
}
|
package/src/dialect.ts
CHANGED
|
@@ -1,145 +1,298 @@
|
|
|
1
|
-
import type
|
|
2
|
-
import {
|
|
3
|
-
import type
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
1
|
+
import { AbstractANSI99Dialect, type ResolvedPathContext, type TableContext } from '@travetto/model-sql';
|
|
2
|
+
import { type Class, castTo, JSONUtil } from '@travetto/runtime';
|
|
3
|
+
import { type SchemaFieldConfig, SchemaRegistryIndex } from '@travetto/schema';
|
|
4
|
+
|
|
5
|
+
/* cspell:words ILIKE regclass indexdef tablename pkey */
|
|
6
|
+
|
|
7
|
+
export class PostgresDialect extends AbstractANSI99Dialect {
|
|
8
|
+
returningSupport = true;
|
|
9
|
+
suggestLikeOperator = 'ILIKE';
|
|
10
|
+
|
|
11
|
+
getComplexColumnType(field: SchemaFieldConfig): string {
|
|
12
|
+
if (field.array && !SchemaRegistryIndex.has(field.type)) {
|
|
13
|
+
const scalarType = this.getColumnType(field);
|
|
14
|
+
return `${scalarType}[]`;
|
|
15
|
+
}
|
|
16
|
+
return 'JSONB';
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
getComplexColumnValue(field: SchemaFieldConfig, value: unknown): unknown {
|
|
20
|
+
if (field.array && !SchemaRegistryIndex.has(field.type)) {
|
|
21
|
+
return value ?? null;
|
|
22
|
+
}
|
|
23
|
+
return super.getComplexColumnValue(field, value);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
getColumnType(fieldConfiguration: SchemaFieldConfig): string {
|
|
27
|
+
if (fieldConfiguration.type === castTo(BigInt)) {
|
|
28
|
+
return 'BIGINT';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (fieldConfiguration.type === Number) {
|
|
32
|
+
if (fieldConfiguration.precision) {
|
|
33
|
+
const [digits, decimals] = fieldConfiguration.precision;
|
|
34
|
+
if (decimals) {
|
|
35
|
+
return `DECIMAL(${digits},${decimals})`;
|
|
36
|
+
}
|
|
37
|
+
if (digits < 5) {
|
|
38
|
+
return 'SMALLINT';
|
|
39
|
+
}
|
|
40
|
+
if (digits < 10) {
|
|
41
|
+
return 'INTEGER';
|
|
42
|
+
}
|
|
43
|
+
return 'BIGINT';
|
|
44
|
+
}
|
|
45
|
+
return 'INTEGER';
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (fieldConfiguration.type === Date) {
|
|
49
|
+
return 'TIMESTAMP(6) WITH TIME ZONE';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (fieldConfiguration.type === Boolean) {
|
|
53
|
+
return 'BOOLEAN';
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (fieldConfiguration.type === String) {
|
|
57
|
+
if (fieldConfiguration.specifiers?.includes('text')) {
|
|
58
|
+
return 'TEXT';
|
|
59
|
+
}
|
|
60
|
+
return `VARCHAR(${fieldConfiguration.maxlength?.limit ?? 1024})`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return 'JSONB';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
compileJsonIndexPath(columnName: string, jsonPath: string[]): string {
|
|
67
|
+
const jsonAccessor = jsonPath
|
|
68
|
+
.slice(0, -1)
|
|
69
|
+
.map(segment => `->'${this.escapeLiteral(segment)}'`)
|
|
70
|
+
.join('');
|
|
71
|
+
const leafSegment = jsonPath[jsonPath.length - 1];
|
|
72
|
+
return `((${columnName}${jsonAccessor}->>'${this.escapeLiteral(leafSegment)}'))`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
override getPlaceholder(index: number): string {
|
|
76
|
+
return `$${index}`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
override getUpsertSQL(
|
|
80
|
+
context: TableContext,
|
|
81
|
+
columns: string[],
|
|
82
|
+
placeholders: string[],
|
|
83
|
+
conflictTarget: string[],
|
|
84
|
+
updates: string[]
|
|
85
|
+
): string {
|
|
86
|
+
return `INSERT INTO ${this.escapeIdentifier(context.tableName)} (${columns.join(', ')}) VALUES (${placeholders.join(', ')}) ON CONFLICT (${conflictTarget.join(', ')}) DO UPDATE SET ${updates.join(', ')} RETURNING *;`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
#buildContainmentPayload(value: unknown, context: ResolvedPathContext): unknown {
|
|
90
|
+
if (!context.subPath || context.subPath.length === 0) {
|
|
91
|
+
return Array.isArray(value) ? value : [value];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const subPathMetadata = this.getSchemaSubPathMetadata(context.arrayField?.type, context.subPath);
|
|
95
|
+
const isArraySegment = subPathMetadata.map(item => item.isArray);
|
|
96
|
+
|
|
97
|
+
const buildPayloadForValue = (item: unknown): unknown => {
|
|
98
|
+
let itemPayload: unknown = item;
|
|
99
|
+
for (let index = context.subPath!.length - 1; index >= 0; index--) {
|
|
100
|
+
const segment = context.subPath![index];
|
|
101
|
+
if (isArraySegment[index]) {
|
|
102
|
+
itemPayload = { [segment]: Array.isArray(itemPayload) ? itemPayload : [itemPayload] };
|
|
103
|
+
} else {
|
|
104
|
+
itemPayload = { [segment]: itemPayload };
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return itemPayload;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
let currentPayload: unknown;
|
|
111
|
+
if (Array.isArray(value)) {
|
|
112
|
+
currentPayload = value.map(item => buildPayloadForValue(item));
|
|
113
|
+
} else {
|
|
114
|
+
currentPayload = [buildPayloadForValue(value)];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (context.arrayPath && context.arrayPath.length > 1) {
|
|
118
|
+
for (let index = context.arrayPath.length - 1; index >= 1; index--) {
|
|
119
|
+
currentPayload = { [context.arrayPath[index]]: currentPayload };
|
|
120
|
+
}
|
|
121
|
+
return currentPayload;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return currentPayload;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
#getPostgresArrayTarget(context: ResolvedPathContext): {
|
|
128
|
+
isNative: boolean;
|
|
129
|
+
sqlPath: string;
|
|
130
|
+
jsonbPath: string;
|
|
131
|
+
buildPayload: (val: unknown) => unknown;
|
|
132
|
+
} {
|
|
133
|
+
const arrayField = context.arrayField ?? context.leafField;
|
|
134
|
+
const isTopLevel = (context.arrayPath?.length ?? 1) === 1;
|
|
135
|
+
const hasSubPath = (context.subPath?.length ?? 0) > 0;
|
|
136
|
+
const isScalarArray = arrayField ? !SchemaRegistryIndex.has(arrayField.type) : true;
|
|
137
|
+
const isNative = isTopLevel && !hasSubPath && isScalarArray;
|
|
138
|
+
|
|
139
|
+
const targetPath =
|
|
140
|
+
hasSubPath && context.arrayPath && context.arrayPath.length > 0 ? this.escapeIdentifier(context.arrayPath[0]) : context.sqlPath;
|
|
141
|
+
|
|
142
|
+
const jsonbPath = isTopLevel && !hasSubPath ? targetPath : `(${targetPath})::jsonb`;
|
|
143
|
+
const buildPayload = (value: unknown): unknown => this.#buildContainmentPayload(value, context);
|
|
144
|
+
|
|
145
|
+
return { isNative, sqlPath: context.sqlPath, jsonbPath, buildPayload };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
compileArrayAll(context: ResolvedPathContext, identifier: string, value: unknown[]): { sql: string; formatted: unknown } {
|
|
149
|
+
const target = this.#getPostgresArrayTarget(context);
|
|
150
|
+
if (target.isNative) {
|
|
151
|
+
return { sql: `${target.sqlPath} @> ${identifier}`, formatted: value };
|
|
152
|
+
}
|
|
153
|
+
return { sql: `${target.jsonbPath} @> ${identifier}::jsonb`, formatted: JSONUtil.toUTF8(target.buildPayload(value)) };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
compileArrayEquals(context: ResolvedPathContext, identifier: string, values: unknown): { sql: string; formatted: unknown } {
|
|
157
|
+
const target = this.#getPostgresArrayTarget(context);
|
|
158
|
+
if (target.isNative) {
|
|
159
|
+
if (Array.isArray(values)) {
|
|
160
|
+
return { sql: `${target.sqlPath} @> ${identifier}`, formatted: values };
|
|
161
|
+
}
|
|
162
|
+
return { sql: `${identifier} = ANY(${target.sqlPath})`, formatted: values };
|
|
163
|
+
}
|
|
164
|
+
return { sql: `${target.jsonbPath} @> ${identifier}::jsonb`, formatted: JSONUtil.toUTF8(target.buildPayload(values)) };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
compileArrayAny(context: ResolvedPathContext, identifier: string, values: unknown[]): { sql: string; formatted: unknown } {
|
|
168
|
+
const target = this.#getPostgresArrayTarget(context);
|
|
169
|
+
if (target.isNative) {
|
|
170
|
+
return { sql: `${target.sqlPath} && ${identifier}`, formatted: values };
|
|
171
|
+
}
|
|
172
|
+
const formatted = values.map(v => JSONUtil.toUTF8(target.buildPayload(v)));
|
|
173
|
+
return { sql: `${target.jsonbPath} @> ANY(${identifier}::jsonb[])`, formatted };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
compileArrayExists(context: ResolvedPathContext, identifier?: string): { sql: string } {
|
|
177
|
+
const target = this.#getPostgresArrayTarget(context);
|
|
178
|
+
if (target.isNative) {
|
|
179
|
+
return { sql: `(${target.sqlPath} IS NOT NULL AND cardinality(${target.sqlPath}) > 0)` };
|
|
180
|
+
}
|
|
181
|
+
return { sql: `(${target.jsonbPath} IS NOT NULL AND ${target.jsonbPath} <> '[]'::jsonb)` };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
compileArrayRegex(context: ResolvedPathContext, identifier: string, value: RegExp | string): { sql: string; formatted: unknown } {
|
|
185
|
+
const target = this.#getPostgresArrayTarget(context);
|
|
186
|
+
const regex = value instanceof RegExp ? value : new RegExp(String(value));
|
|
187
|
+
const caseInsensitive = regex.flags.includes('i');
|
|
188
|
+
const regexOp = this.getRegexOperator(caseInsensitive);
|
|
189
|
+
const regexSource = this.formatRegex(regex.source, caseInsensitive);
|
|
190
|
+
|
|
191
|
+
if (target.isNative) {
|
|
192
|
+
return {
|
|
193
|
+
sql: `EXISTS (SELECT 1 FROM unnest(${target.sqlPath}) AS elem WHERE elem ${regexOp} ${identifier})`,
|
|
194
|
+
formatted: regexSource
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const subAccessor =
|
|
199
|
+
context.subPath && context.subPath.length > 0
|
|
200
|
+
? `->${context.subPath.map(segment => `'${this.escapeLiteral(segment)}'`).join('->')}`
|
|
201
|
+
: '';
|
|
108
202
|
|
|
109
203
|
return {
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
type: col.type.toUpperCase()
|
|
113
|
-
.replace('CHARACTER VARYING', 'VARCHAR')
|
|
114
|
-
.replace('INTEGER', 'INT'),
|
|
115
|
-
is_not_null: !!col.is_not_null
|
|
116
|
-
})),
|
|
117
|
-
foreignKeys: foreignKeys.records,
|
|
118
|
-
indices: indices.records
|
|
119
|
-
.map(idx => ({
|
|
120
|
-
name: idx.name,
|
|
121
|
-
is_unique: idx.is_unique,
|
|
122
|
-
columns: idx.columns
|
|
123
|
-
.map(column => column.split(' '))
|
|
124
|
-
.map(([name, desc]) => ({ name, desc: desc === '1' }))
|
|
125
|
-
}))
|
|
204
|
+
sql: `EXISTS (SELECT 1 FROM jsonb_array_elements_text((${target.jsonbPath})${subAccessor}) AS elem WHERE elem ${regexOp} ${identifier})`,
|
|
205
|
+
formatted: regexSource
|
|
126
206
|
};
|
|
127
207
|
}
|
|
128
208
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
209
|
+
getRegexOperator(caseInsensitive: boolean): string {
|
|
210
|
+
return caseInsensitive ? '~*' : '~';
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
formatRegex(source: string, caseInsensitive: boolean): string {
|
|
214
|
+
return source.replaceAll('\\b', '\\y');
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
castColumn(sqlPath: string, type: Class): string {
|
|
218
|
+
if (type === Number) {
|
|
219
|
+
return `(${sqlPath})::NUMERIC`;
|
|
220
|
+
} else if (type === Boolean) {
|
|
221
|
+
return `(${sqlPath})::BOOLEAN`;
|
|
222
|
+
} else if (type === Date) {
|
|
223
|
+
return `(${sqlPath})::TIMESTAMP WITH TIME ZONE`;
|
|
224
|
+
} else if (type === String) {
|
|
225
|
+
return `(${sqlPath})::text`;
|
|
226
|
+
}
|
|
227
|
+
return sqlPath;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
shiftPlaceholders(sql: string, offset: number): string {
|
|
231
|
+
return sql.replaceAll(/[$](\d+)/g, (_, num) => `$${Number(num) + offset}`);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
getTableExistsQuery(context: TableContext): { sql: string; parameters?: unknown[] } {
|
|
235
|
+
return {
|
|
236
|
+
sql: `SELECT EXISTS (
|
|
237
|
+
SELECT FROM pg_catalog.pg_class c
|
|
238
|
+
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
|
|
239
|
+
WHERE c.relname = $1 AND c.relkind = 'r'
|
|
240
|
+
);`,
|
|
241
|
+
parameters: [context.tableName]
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
parseTableExistsResult(records: unknown[]): boolean {
|
|
246
|
+
return castTo<{ exists: boolean }>(records[0])?.exists ?? false;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
getExistingColumnsQuery(context: TableContext): { sql: string; parameters?: unknown[] } {
|
|
250
|
+
return {
|
|
251
|
+
sql: `SELECT a.attname AS name, pg_catalog.format_type(a.atttypid, a.atttypmod) AS type
|
|
252
|
+
FROM pg_catalog.pg_attribute a
|
|
253
|
+
WHERE a.attrelid = $1::regclass AND a.attnum > 0 AND NOT a.attisdropped;`,
|
|
254
|
+
parameters: [context.tableName]
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
parseExistingColumns(records: unknown[]): Map<string, string> {
|
|
259
|
+
return new Map(castTo<{ name: string; type: string }[]>(records).map(record => [record.name, record.type.toUpperCase()]));
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
getAlterColumnTypeSQL(context: TableContext, columnName: string, columnType: string, existingType: string): string | undefined {
|
|
263
|
+
const normalizedExisting = existingType.replace('CHARACTER VARYING', 'VARCHAR').replace('INTEGER', 'INT');
|
|
264
|
+
const normalizedRequested = columnType.toUpperCase().replace('CHARACTER VARYING', 'VARCHAR').replace('INTEGER', 'INT');
|
|
265
|
+
|
|
266
|
+
if (!normalizedExisting.startsWith(normalizedRequested) && !normalizedRequested.startsWith(normalizedExisting)) {
|
|
267
|
+
return `ALTER TABLE ${this.escapeIdentifier(context.tableName)} ALTER COLUMN ${this.escapeIdentifier(columnName)} TYPE ${columnType} USING (${this.escapeIdentifier(columnName)}::${columnType});`;
|
|
268
|
+
}
|
|
269
|
+
return undefined;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
getExistingIndexesQuery(context: TableContext): { sql: string; parameters?: unknown[] } {
|
|
273
|
+
return {
|
|
274
|
+
sql: `SELECT indexname, indexdef FROM pg_indexes WHERE tablename = $1;`,
|
|
275
|
+
parameters: [context.tableName]
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
parseExistingIndexes(records: unknown[]): Map<string, string> {
|
|
280
|
+
return new Map(
|
|
281
|
+
castTo<{ indexname: string; indexdef: string }[]>(records)
|
|
282
|
+
.filter(record => !record.indexname.endsWith('_pkey'))
|
|
283
|
+
.map(record => [record.indexname, record.indexdef])
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
getDropIndexSQL(context: TableContext, indexName: string): string {
|
|
288
|
+
return `DROP INDEX IF EXISTS ${this.escapeIdentifier(indexName)};`;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
getDropTableSQL(context: TableContext): string {
|
|
292
|
+
return `DROP TABLE IF EXISTS ${this.escapeIdentifier(context.tableName)} CASCADE;`;
|
|
137
293
|
}
|
|
138
294
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
*/
|
|
142
|
-
override getTruncateAllTablesSQL<T extends ModelType>(cls: Class<T>): string[] {
|
|
143
|
-
return [`TRUNCATE ${this.table(SQLModelUtil.classToStack(cls))} CASCADE;`];
|
|
295
|
+
getTruncateTableSQL(context: TableContext): string {
|
|
296
|
+
return `TRUNCATE TABLE ${this.escapeIdentifier(context.tableName)} CASCADE;`;
|
|
144
297
|
}
|
|
145
|
-
}
|
|
298
|
+
}
|
package/src/service.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { default as pg } from 'pg';
|
|
2
|
+
|
|
3
|
+
import { Injectable, PostConstruct } from '@travetto/di';
|
|
4
|
+
import { BaseSQLModelService } from '@travetto/model-sql';
|
|
5
|
+
|
|
6
|
+
import type { PostgresConnection } from './connection.ts';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A PostgreSQL JSON-based document store model service
|
|
10
|
+
*/
|
|
11
|
+
@Injectable()
|
|
12
|
+
export class PostgresModelService extends BaseSQLModelService {
|
|
13
|
+
connection: PostgresConnection;
|
|
14
|
+
|
|
15
|
+
constructor(connection: PostgresConnection) {
|
|
16
|
+
super();
|
|
17
|
+
this.connection = connection;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
get client(): pg.Pool {
|
|
21
|
+
return this.connection.pool;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
@PostConstruct()
|
|
25
|
+
override async initialize(): Promise<void> {
|
|
26
|
+
await super.initialize();
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ServiceDescriptor } from '@travetto/cli';
|
|
2
2
|
|
|
3
|
-
const version = process.env.POSTGRESQL_VERSION || '18.
|
|
3
|
+
const version = process.env.POSTGRESQL_VERSION || '18.6';
|
|
4
4
|
|
|
5
5
|
export const service: ServiceDescriptor = {
|
|
6
6
|
name: 'postgresql',
|
|
@@ -12,4 +12,4 @@ export const service: ServiceDescriptor = {
|
|
|
12
12
|
POSTGRES_PASSWORD: 'travetto',
|
|
13
13
|
POSTGRES_DB: 'app'
|
|
14
14
|
}
|
|
15
|
-
};
|
|
15
|
+
};
|