@travetto/model-postgres 8.0.0-alpha.25 → 8.0.0-alpha.26
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 +29 -24
- package/__index__.ts +2 -1
- package/package.json +2 -2
- package/src/config.ts +50 -0
- package/src/connection.ts +65 -44
- package/src/dialect.ts +210 -127
- package/src/service.ts +28 -0
package/README.md
CHANGED
|
@@ -13,13 +13,17 @@ 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. 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 [
|
|
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. Every table generated
|
|
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
24
|
* [Bulk](https://github.com/travetto/travetto/tree/main/module/model/src/types/bulk.ts#L60)
|
|
22
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)
|
|
23
27
|
* [Indexed](https://github.com/travetto/travetto/tree/main/module/model-indexed/src/types/service.ts#L21)
|
|
24
28
|
* [Query Crud](https://github.com/travetto/travetto/tree/main/module/model-query/src/types/crud.ts#L11)
|
|
25
29
|
* [Facet](https://github.com/travetto/travetto/tree/main/module/model-query/src/types/facet.ts#L14)
|
|
@@ -30,61 +34,62 @@ Out of the box, by installing the module, everything should be wired up by defau
|
|
|
30
34
|
|
|
31
35
|
**Code: Wiring up a custom Model Source**
|
|
32
36
|
```typescript
|
|
33
|
-
import type { AsyncContext } from '@travetto/context';
|
|
34
37
|
import { InjectableFactory } from '@travetto/di';
|
|
35
|
-
import {
|
|
36
|
-
import { type SQLModelConfig, SQLModelService } from '@travetto/model-sql';
|
|
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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@travetto/model-postgres",
|
|
3
|
-
"version": "8.0.0-alpha.
|
|
3
|
+
"version": "8.0.0-alpha.26",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "PostgreSQL backing for the travetto model module, with real-time modeling support for SQL schemas.",
|
|
6
6
|
"keywords": [
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"@travetto/context": "^8.0.0-alpha.20",
|
|
33
33
|
"@travetto/model": "^8.0.0-alpha.23",
|
|
34
34
|
"@travetto/model-query": "^8.0.0-alpha.24",
|
|
35
|
-
"@travetto/model-sql": "^8.0.0-alpha.
|
|
35
|
+
"@travetto/model-sql": "^8.0.0-alpha.26",
|
|
36
36
|
"@types/pg": "^8.20.0",
|
|
37
37
|
"pg": "^8.22.0"
|
|
38
38
|
},
|
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,66 +1,95 @@
|
|
|
1
1
|
import { type Pool, type PoolClient, default as pg } from 'pg';
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import type { AsyncContext } from '@travetto/context';
|
|
4
|
+
import { Injectable } from '@travetto/di';
|
|
4
5
|
import { ExistsError } from '@travetto/model';
|
|
5
|
-
import {
|
|
6
|
+
import { SQLConnection } from '@travetto/model-sql';
|
|
6
7
|
import { castTo, ShutdownManager } from '@travetto/runtime';
|
|
7
8
|
|
|
9
|
+
import type { PostgresModelConfig } from './config.ts';
|
|
10
|
+
import { PostgresDialect } from './dialect.ts';
|
|
11
|
+
|
|
8
12
|
/**
|
|
9
|
-
*
|
|
13
|
+
* PostgreSQL connection manager
|
|
10
14
|
*/
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
15
|
+
@Injectable()
|
|
16
|
+
export class PostgresConnection extends SQLConnection<PoolClient> {
|
|
17
|
+
pool: Pool;
|
|
18
|
+
|
|
19
|
+
readonly dialect = new PostgresDialect();
|
|
20
|
+
readonly config: PostgresModelConfig;
|
|
14
21
|
|
|
15
|
-
constructor(context: AsyncContext, config:
|
|
22
|
+
constructor(context: AsyncContext, config: PostgresModelConfig) {
|
|
16
23
|
super(context);
|
|
17
|
-
this
|
|
24
|
+
this.config = config;
|
|
18
25
|
}
|
|
19
26
|
|
|
20
27
|
/**
|
|
21
|
-
* Initializes
|
|
28
|
+
* Initializes the pool and creates the pgcrypto extension
|
|
22
29
|
*/
|
|
23
|
-
@WithAsyncContext()
|
|
24
30
|
async init(): Promise<void> {
|
|
25
|
-
this
|
|
26
|
-
user: this
|
|
27
|
-
password: this
|
|
28
|
-
database: this
|
|
29
|
-
host: this
|
|
30
|
-
port: this
|
|
31
|
+
this.pool = new pg.Pool({
|
|
32
|
+
user: this.config.user,
|
|
33
|
+
password: this.config.password,
|
|
34
|
+
database: this.config.database,
|
|
35
|
+
host: this.config.host,
|
|
36
|
+
port: this.config.port,
|
|
31
37
|
...castTo({
|
|
32
38
|
parseInputDatesAsUTC: true
|
|
33
39
|
}),
|
|
34
|
-
...
|
|
40
|
+
...this.config.options
|
|
35
41
|
});
|
|
36
42
|
|
|
37
|
-
|
|
38
|
-
this.
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
);
|
|
43
|
+
try {
|
|
44
|
+
await this.execute('CREATE EXTENSION IF NOT EXISTS pgcrypto;');
|
|
45
|
+
} catch (error) {
|
|
46
|
+
if (!(error instanceof Error && error.message.includes('already exists'))) {
|
|
47
|
+
throw error;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
ShutdownManager.signal.addEventListener('abort', () => this.pool.end());
|
|
52
|
+
}
|
|
46
53
|
|
|
47
|
-
|
|
48
|
-
|
|
54
|
+
/**
|
|
55
|
+
* Acquires a client from the pool
|
|
56
|
+
*/
|
|
57
|
+
acquire(): Promise<PoolClient> {
|
|
58
|
+
return this.pool.connect();
|
|
49
59
|
}
|
|
50
60
|
|
|
51
|
-
|
|
52
|
-
|
|
61
|
+
/**
|
|
62
|
+
* Releases a client back to the pool
|
|
63
|
+
*/
|
|
64
|
+
release(connection: PoolClient): void {
|
|
65
|
+
connection.release();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Executes a query on the active client or pool directly
|
|
70
|
+
*/
|
|
71
|
+
async execute<Type = unknown>(query: string, values?: unknown[]): Promise<{ count: number; records: Type[] }> {
|
|
72
|
+
console.debug('Executing PostgreSQL query', { query, values });
|
|
73
|
+
|
|
74
|
+
// Handle dynamically built savepoint names that cannot be parameterized in Postgres
|
|
75
|
+
if (query.includes('SAVEPOINT') || query.includes('ROLLBACK TO') || query.includes('RELEASE SAVEPOINT')) {
|
|
76
|
+
if (values && values.length > 0) {
|
|
77
|
+
query = query.replace('$1', `"${values[0]}"`);
|
|
78
|
+
values = [];
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const client = this.active ?? this.pool;
|
|
83
|
+
|
|
53
84
|
try {
|
|
54
|
-
const
|
|
55
|
-
const records:
|
|
56
|
-
return { count:
|
|
85
|
+
const result = await client.query(query, values);
|
|
86
|
+
const records: Type[] = [...result.rows].map(row => ({ ...row }));
|
|
87
|
+
return { count: result.rowCount ?? 0, records };
|
|
57
88
|
} catch (error) {
|
|
58
|
-
const code = error && typeof error === 'object' && 'code' in error ? error.code : undefined;
|
|
89
|
+
const code = error && typeof error === 'object' && 'code' in error ? castTo<Record<string, unknown>>(error).code : undefined;
|
|
59
90
|
switch (code) {
|
|
60
|
-
// Index already exists
|
|
61
91
|
case '42P07':
|
|
62
92
|
throw new ExistsError('index', query);
|
|
63
|
-
// Unique violation
|
|
64
93
|
case '23505':
|
|
65
94
|
throw new ExistsError('query', query);
|
|
66
95
|
default:
|
|
@@ -68,12 +97,4 @@ export class PostgreSQLConnection extends Connection<PoolClient> {
|
|
|
68
97
|
}
|
|
69
98
|
}
|
|
70
99
|
}
|
|
71
|
-
|
|
72
|
-
acquire(): Promise<PoolClient> {
|
|
73
|
-
return this.#pool.connect();
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
release(pool: PoolClient): void {
|
|
77
|
-
pool.release();
|
|
78
|
-
}
|
|
79
100
|
}
|
package/src/dialect.ts
CHANGED
|
@@ -1,138 +1,221 @@
|
|
|
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
|
-
|
|
1
|
+
import { AbstractANSI99Dialect, 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
|
+
export class PostgresDialect extends AbstractANSI99Dialect {
|
|
6
|
+
returningSupport = true;
|
|
7
|
+
suggestLikeOperator = 'ILIKE';
|
|
8
|
+
|
|
9
|
+
getComplexColumnType(field: SchemaFieldConfig): string {
|
|
10
|
+
if (field.array && !SchemaRegistryIndex.has(field.type)) {
|
|
11
|
+
const scalarType = this.getColumnType(field);
|
|
12
|
+
return `${scalarType}[]`;
|
|
13
|
+
}
|
|
14
|
+
return 'JSONB';
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
getComplexColumnValue(field: SchemaFieldConfig, value: unknown): unknown {
|
|
18
|
+
if (field.array && !SchemaRegistryIndex.has(field.type)) {
|
|
19
|
+
return value ?? null;
|
|
20
|
+
}
|
|
21
|
+
return super.getComplexColumnValue(field, value);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
getColumnType(fieldConfiguration: SchemaFieldConfig): string {
|
|
25
|
+
if (fieldConfiguration.type === castTo(BigInt)) {
|
|
26
|
+
return 'BIGINT';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (fieldConfiguration.type === Number) {
|
|
30
|
+
if (fieldConfiguration.precision) {
|
|
31
|
+
const [digits, decimals] = fieldConfiguration.precision;
|
|
32
|
+
if (decimals) {
|
|
33
|
+
return `DECIMAL(${digits},${decimals})`;
|
|
34
|
+
}
|
|
35
|
+
if (digits < 5) {
|
|
36
|
+
return 'SMALLINT';
|
|
37
|
+
}
|
|
38
|
+
if (digits < 10) {
|
|
39
|
+
return 'INTEGER';
|
|
40
|
+
}
|
|
41
|
+
return 'BIGINT';
|
|
42
|
+
}
|
|
43
|
+
return 'INTEGER';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (fieldConfiguration.type === Date) {
|
|
47
|
+
return 'TIMESTAMP(6) WITH TIME ZONE';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (fieldConfiguration.type === Boolean) {
|
|
51
|
+
return 'BOOLEAN';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (fieldConfiguration.type === String) {
|
|
55
|
+
if (fieldConfiguration.specifiers?.includes('text')) {
|
|
56
|
+
return 'TEXT';
|
|
57
|
+
}
|
|
58
|
+
return `VARCHAR(${fieldConfiguration.maxlength?.limit ?? 1024})`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return 'JSONB';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
compileJsonIndexPath(columnName: string, jsonPath: string[]): string {
|
|
65
|
+
const jsonAccessor = jsonPath
|
|
66
|
+
.slice(0, -1)
|
|
67
|
+
.map(segment => `->'${this.escapeLiteral(segment)}'`)
|
|
68
|
+
.join('');
|
|
69
|
+
const leafSegment = jsonPath[jsonPath.length - 1];
|
|
70
|
+
return `((${columnName}${jsonAccessor}->>'${this.escapeLiteral(leafSegment)}'))`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
override getPlaceholder(index: number): string {
|
|
74
|
+
return `$${index}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
compileArrayAll(
|
|
78
|
+
sqlPath: string,
|
|
79
|
+
identifier: string,
|
|
80
|
+
value: unknown[],
|
|
81
|
+
field: SchemaFieldConfig,
|
|
82
|
+
topLevel?: boolean
|
|
83
|
+
): { sql: string; formatted: unknown } {
|
|
84
|
+
if (topLevel && !SchemaRegistryIndex.has(field.type)) {
|
|
85
|
+
return { sql: `${sqlPath} @> ${identifier}`, formatted: value };
|
|
86
|
+
}
|
|
87
|
+
const jsonbPath = topLevel ? sqlPath : `(${sqlPath})::jsonb`;
|
|
88
|
+
return { sql: `${jsonbPath} @> ${identifier}::jsonb`, formatted: JSONUtil.toUTF8(value) };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
compileArrayEquals(
|
|
92
|
+
sqlPath: string,
|
|
93
|
+
identifier: string,
|
|
94
|
+
values: unknown,
|
|
95
|
+
field: SchemaFieldConfig,
|
|
96
|
+
topLevel?: boolean
|
|
97
|
+
): { sql: string; formatted: unknown } {
|
|
98
|
+
if (topLevel && !SchemaRegistryIndex.has(field.type)) {
|
|
99
|
+
if (Array.isArray(values)) {
|
|
100
|
+
return { sql: `${sqlPath} @> ${identifier}`, formatted: values };
|
|
101
|
+
}
|
|
102
|
+
return { sql: `${identifier} = ANY(${sqlPath})`, formatted: values };
|
|
103
|
+
}
|
|
104
|
+
const jsonbPath = topLevel ? sqlPath : `(${sqlPath})::jsonb`;
|
|
105
|
+
const val = Array.isArray(values) ? values : [values];
|
|
106
|
+
return { sql: `${jsonbPath} @> ${identifier}::jsonb`, formatted: JSONUtil.toUTF8(val) };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
compileArrayAny(
|
|
110
|
+
sqlPath: string,
|
|
111
|
+
identifier: string,
|
|
112
|
+
values: unknown[],
|
|
113
|
+
field: SchemaFieldConfig,
|
|
114
|
+
topLevel?: boolean
|
|
115
|
+
): { sql: string; formatted: unknown } {
|
|
116
|
+
if (topLevel && !SchemaRegistryIndex.has(field.type)) {
|
|
117
|
+
return { sql: `${sqlPath} && ${identifier}`, formatted: values };
|
|
118
|
+
}
|
|
119
|
+
const jsonbPath = topLevel ? sqlPath : `(${sqlPath})::jsonb`;
|
|
120
|
+
const formatted = values.map(v => JSONUtil.toUTF8(Array.isArray(v) ? v : [v]));
|
|
121
|
+
return { sql: `${jsonbPath} @> ANY(${identifier}::jsonb[])`, formatted };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
compileArrayExists(sqlPath: string, identifier: string, field: SchemaFieldConfig, topLevel?: boolean): { sql: string } {
|
|
125
|
+
if (topLevel && !SchemaRegistryIndex.has(field.type)) {
|
|
126
|
+
return { sql: `(${sqlPath} IS NOT NULL AND cardinality(${sqlPath}) > 0)` };
|
|
127
|
+
}
|
|
128
|
+
const jsonbPath = topLevel ? sqlPath : `(${sqlPath})::jsonb`;
|
|
129
|
+
return { sql: `(${sqlPath} IS NOT NULL AND ${jsonbPath} <> '[]'::jsonb)` };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
getRegexOperator(caseInsensitive: boolean): string {
|
|
133
|
+
return caseInsensitive ? '~*' : '~';
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
formatRegex(source: string, caseInsensitive: boolean): string {
|
|
137
|
+
return source.replaceAll('\\b', '\\y');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
castColumn(sqlPath: string, type: Class): string {
|
|
141
|
+
if (type === Number) {
|
|
142
|
+
return `(${sqlPath})::NUMERIC`;
|
|
143
|
+
} else if (type === Boolean) {
|
|
144
|
+
return `(${sqlPath})::BOOLEAN`;
|
|
145
|
+
} else if (type === Date) {
|
|
146
|
+
return `(${sqlPath})::TIMESTAMP WITH TIME ZONE`;
|
|
147
|
+
} else if (type === String) {
|
|
148
|
+
return `(${sqlPath})::text`;
|
|
70
149
|
}
|
|
150
|
+
return sqlPath;
|
|
151
|
+
}
|
|
71
152
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
tc.constraint_name AS name,
|
|
76
|
-
kcu.column_name AS from_column,
|
|
77
|
-
ccu.column_name AS to_column,
|
|
78
|
-
ccu.table_name AS to_table
|
|
79
|
-
FROM information_schema.table_constraints AS tc
|
|
80
|
-
JOIN information_schema.key_column_usage AS kcu
|
|
81
|
-
ON tc.constraint_name = kcu.constraint_name
|
|
82
|
-
JOIN information_schema.constraint_column_usage AS ccu
|
|
83
|
-
ON ccu.constraint_name = tc.constraint_name
|
|
84
|
-
WHERE tc.constraint_type = 'FOREIGN KEY'
|
|
85
|
-
AND tc.table_name = '${table}'
|
|
86
|
-
`);
|
|
87
|
-
|
|
88
|
-
// 3. Indices
|
|
89
|
-
const indices = await this.executeSQL<{ name: string; is_unique: boolean; columns: string[] }>(`
|
|
90
|
-
SELECT
|
|
91
|
-
i.relname AS name,
|
|
92
|
-
ix.indisunique AS is_unique,
|
|
93
|
-
ARRAY_AGG(a.attname || ' '|| CAST((o.OPTION & 1) AS VARCHAR) ORDER BY array_position(ix.indkey, a.attnum)) AS columns
|
|
94
|
-
FROM pg_class t
|
|
95
|
-
JOIN pg_index ix ON t.oid = ix.indrelid
|
|
96
|
-
JOIN pg_class i ON i.oid = ix.indexrelid
|
|
97
|
-
CROSS JOIN LATERAL UNNEST(ix.indkey) WITH ordinality AS c (colnum, ordinality)
|
|
98
|
-
LEFT JOIN LATERAL UNNEST(ix.indoption) WITH ordinality AS o (OPTION, ordinality) ON c.ordinality = o.ordinality
|
|
99
|
-
LEFT JOIN pg_catalog.pg_constraint co ON co.conindid = ix.indexrelid
|
|
100
|
-
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = c.colnum
|
|
101
|
-
WHERE t.relname = '${table}'
|
|
102
|
-
AND NOT ix.indisprimary
|
|
103
|
-
AND co.conindid IS NULL
|
|
104
|
-
GROUP BY i.relname, ix.indisunique
|
|
105
|
-
`);
|
|
153
|
+
shiftPlaceholders(sql: string, offset: number): string {
|
|
154
|
+
return sql.replaceAll(/[$](\d+)/g, (_, num) => `$${Number(num) + offset}`);
|
|
155
|
+
}
|
|
106
156
|
|
|
157
|
+
getTableExistsQuery(context: TableContext): { sql: string; parameters?: unknown[] } {
|
|
107
158
|
return {
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
indices: indices.records.map(idx => ({
|
|
115
|
-
name: idx.name,
|
|
116
|
-
is_unique: idx.is_unique,
|
|
117
|
-
columns: idx.columns.map(column => column.split(' ')).map(([name, desc]) => ({ name, desc: desc === '1' }))
|
|
118
|
-
}))
|
|
159
|
+
sql: `SELECT EXISTS (
|
|
160
|
+
SELECT FROM pg_catalog.pg_class c
|
|
161
|
+
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
|
|
162
|
+
WHERE c.relname = $1 AND c.relkind = 'r'
|
|
163
|
+
);`,
|
|
164
|
+
parameters: [context.tableName]
|
|
119
165
|
};
|
|
120
166
|
}
|
|
121
167
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
168
|
+
parseTableExistsResult(records: unknown[]): boolean {
|
|
169
|
+
return castTo<{ exists: boolean }>(records[0])?.exists ?? false;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
getExistingColumnsQuery(context: TableContext): { sql: string; parameters?: unknown[] } {
|
|
173
|
+
return {
|
|
174
|
+
sql: `SELECT a.attname AS name, pg_catalog.format_type(a.atttypid, a.atttypmod) AS type
|
|
175
|
+
FROM pg_catalog.pg_attribute a
|
|
176
|
+
WHERE a.attrelid = $1::regclass AND a.attnum > 0 AND NOT a.attisdropped;`,
|
|
177
|
+
parameters: [context.tableName]
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
parseExistingColumns(records: unknown[]): Map<string, string> {
|
|
182
|
+
return new Map(castTo<{ name: string; type: string }[]>(records).map(record => [record.name, record.type.toUpperCase()]));
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
getAlterColumnTypeSQL(context: TableContext, columnName: string, columnType: string, existingType: string): string | undefined {
|
|
186
|
+
const normalizedExisting = existingType.replace('CHARACTER VARYING', 'VARCHAR').replace('INTEGER', 'INT');
|
|
187
|
+
const normalizedRequested = columnType.toUpperCase().replace('CHARACTER VARYING', 'VARCHAR').replace('INTEGER', 'INT');
|
|
188
|
+
|
|
189
|
+
if (!normalizedExisting.startsWith(normalizedRequested) && !normalizedRequested.startsWith(normalizedExisting)) {
|
|
190
|
+
return `ALTER TABLE ${this.escapeIdentifier(context.tableName)} ALTER COLUMN ${this.escapeIdentifier(columnName)} TYPE ${columnType} USING (${this.escapeIdentifier(columnName)}::${columnType});`;
|
|
191
|
+
}
|
|
192
|
+
return undefined;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
getExistingIndexesQuery(context: TableContext): { sql: string; parameters?: unknown[] } {
|
|
196
|
+
return {
|
|
197
|
+
sql: `SELECT indexname, indexdef FROM pg_indexes WHERE tablename = $1;`,
|
|
198
|
+
parameters: [context.tableName]
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
parseExistingIndexes(records: unknown[]): Map<string, string> {
|
|
203
|
+
return new Map(
|
|
204
|
+
castTo<{ indexname: string; indexdef: string }[]>(records)
|
|
205
|
+
.filter(record => !record.indexname.endsWith('_pkey'))
|
|
206
|
+
.map(record => [record.indexname, record.indexdef])
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
getDropIndexSQL(context: TableContext, indexName: string): string {
|
|
211
|
+
return `DROP INDEX IF EXISTS ${this.escapeIdentifier(indexName)};`;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
getDropTableSQL(context: TableContext): string {
|
|
215
|
+
return `DROP TABLE IF EXISTS ${this.escapeIdentifier(context.tableName)} CASCADE;`;
|
|
130
216
|
}
|
|
131
217
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
*/
|
|
135
|
-
override getTruncateAllTablesSQL<T extends ModelType>(cls: Class<T>): string[] {
|
|
136
|
-
return [`TRUNCATE ${this.table(SQLModelUtil.classToStack(cls))} CASCADE;`];
|
|
218
|
+
getTruncateTableSQL(context: TableContext): string {
|
|
219
|
+
return `TRUNCATE TABLE ${this.escapeIdentifier(context.tableName)} CASCADE;`;
|
|
137
220
|
}
|
|
138
221
|
}
|
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
|
+
}
|