@travetto/model-postgres 8.0.0-alpha.24 → 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 CHANGED
@@ -13,79 +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. 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 [SQLModelService](https://github.com/travetto/travetto/tree/main/module/model-sql/src/service.ts#L36) will also modify the database schema in real time to minimize impact to development.
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 will have a `path_id` which determines it's location in the document hierarchy as well as sub tables will have a `parent_path_id` to associate records with the parent values.
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
- * [CRUD](https://github.com/travetto/travetto/tree/main/module/model/src/types/crud.ts#L11)
22
- * [Bulk](https://github.com/travetto/travetto/tree/main/module/model/src/types/bulk.ts#L64)
23
- * [Indexed](https://github.com/travetto/travetto/tree/main/module/model-indexed/src/types/service.ts#L16)
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)
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)
26
- * [Query](https://github.com/travetto/travetto/tree/main/module/model-query/src/types/query.ts#L10)
27
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)
28
32
 
29
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.
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
-
36
- import { SQLModelService, type SQLModelConfig } from '@travetto/model-sql';
37
- import { PostgreSQLDialect } from '@travetto/model-postgres';
38
+ import { type PostgresConnection, PostgresModelService } from '@travetto/model-postgres';
38
39
 
39
40
  export class Init {
40
41
  @InjectableFactory({ primary: true })
41
- static getModelService(ctx: AsyncContext, config: SQLModelConfig) {
42
- return new SQLModelService(ctx, config, new PostgreSQLDialect(ctx, config));
42
+ static getModelService(connection: PostgresConnection) {
43
+ return new PostgresModelService(connection);
43
44
  }
44
45
  }
45
46
  ```
46
47
 
47
- where the [SQLModelConfig](https://github.com/travetto/travetto/tree/main/module/model-sql/src/config.ts#L8) is defined by:
48
+ where the [PostgresModelConfig](https://github.com/travetto/travetto/tree/main/module/model-postgres/src/config.ts#L10) is defined by:
48
49
 
49
- **Code: Structure of SQLModelConfig**
50
+ **Code: Structure of PostgresModelConfig**
50
51
  ```typescript
51
- @Config('model.sql')
52
- export class SQLModelConfig<T extends {} = {}> {
52
+ @Config('model.postgres')
53
+ export class PostgresModelConfig {
53
54
  /**
54
- * Host to connect to
55
+ * Database host to connect to
55
56
  */
56
57
  host = '127.0.0.1';
58
+
57
59
  /**
58
- * Default port
60
+ * Database port to connect to
59
61
  */
60
62
  port = 0;
63
+
61
64
  /**
62
- * Username
65
+ * Database username
63
66
  */
64
67
  user = Runtime.production ? '' : 'travetto';
68
+
65
69
  /**
66
- * Password
70
+ * Database password
67
71
  */
68
72
  password = Runtime.production ? '' : 'travetto';
73
+
69
74
  /**
70
- * Table prefix
75
+ * Namespace/schema prefix for table names
71
76
  */
72
77
  namespace = '';
78
+
73
79
  /**
74
80
  * Database name
75
81
  */
76
82
  database = 'app';
83
+
77
84
  /**
78
- * Allow storage modification at runtime
79
- */
80
- modifyStorage?: boolean;
81
- /**
82
- * Db version
85
+ * Allow storage modifications (like table auto-creation and schema updates) at runtime
83
86
  */
84
- version = '';
87
+ modifyStorage = !Runtime.production;
88
+
85
89
  /**
86
- * Raw client options
90
+ * Client specific overrides
87
91
  */
88
- options: T = asFull({});
92
+ options?: PG.ClientConfig;
89
93
  }
90
94
  ```
91
95
 
package/__index__.ts CHANGED
@@ -1,2 +1,3 @@
1
- export * from './src/dialect.ts';
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,6 +1,6 @@
1
1
  {
2
2
  "name": "@travetto/model-postgres",
3
- "version": "8.0.0-alpha.24",
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": [
@@ -28,16 +28,16 @@
28
28
  "directory": "module/model-postgres"
29
29
  },
30
30
  "dependencies": {
31
- "@travetto/config": "^8.0.0-alpha.21",
32
- "@travetto/context": "^8.0.0-alpha.19",
33
- "@travetto/model": "^8.0.0-alpha.22",
34
- "@travetto/model-query": "^8.0.0-alpha.23",
35
- "@travetto/model-sql": "^8.0.0-alpha.24",
31
+ "@travetto/config": "^8.0.0-alpha.22",
32
+ "@travetto/context": "^8.0.0-alpha.20",
33
+ "@travetto/model": "^8.0.0-alpha.23",
34
+ "@travetto/model-query": "^8.0.0-alpha.24",
35
+ "@travetto/model-sql": "^8.0.0-alpha.26",
36
36
  "@types/pg": "^8.20.0",
37
37
  "pg": "^8.22.0"
38
38
  },
39
39
  "peerDependencies": {
40
- "@travetto/cli": "^8.0.0-alpha.27"
40
+ "@travetto/cli": "^8.0.0-alpha.28"
41
41
  },
42
42
  "peerDependenciesMeta": {
43
43
  "@travetto/cli": {
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,100 @@
1
1
  import { type Pool, type PoolClient, default as pg } from 'pg';
2
2
 
3
- import { castTo, ShutdownManager } from '@travetto/runtime';
4
- import { type AsyncContext, WithAsyncContext } from '@travetto/context';
3
+ import type { AsyncContext } from '@travetto/context';
4
+ import { Injectable } from '@travetto/di';
5
5
  import { ExistsError } from '@travetto/model';
6
- import { type SQLModelConfig, Connection } from '@travetto/model-sql';
6
+ import { SQLConnection } from '@travetto/model-sql';
7
+ import { castTo, ShutdownManager } from '@travetto/runtime';
8
+
9
+ import type { PostgresModelConfig } from './config.ts';
10
+ import { PostgresDialect } from './dialect.ts';
7
11
 
8
12
  /**
9
- * Connection support for postgresql
13
+ * PostgreSQL connection manager
10
14
  */
11
- export class PostgreSQLConnection extends Connection<PoolClient> {
15
+ @Injectable()
16
+ export class PostgresConnection extends SQLConnection<PoolClient> {
17
+ pool: Pool;
12
18
 
13
- #pool: Pool;
14
- #config: SQLModelConfig;
19
+ readonly dialect = new PostgresDialect();
20
+ readonly config: PostgresModelConfig;
15
21
 
16
- constructor(
17
- context: AsyncContext,
18
- config: SQLModelConfig
19
- ) {
22
+ constructor(context: AsyncContext, config: PostgresModelConfig) {
20
23
  super(context);
21
- this.#config = config;
24
+ this.config = config;
22
25
  }
23
26
 
24
27
  /**
25
- * Initializes connection and establishes crypto extension for use with hashing
28
+ * Initializes the pool and creates the pgcrypto extension
26
29
  */
27
- @WithAsyncContext()
28
30
  async init(): Promise<void> {
29
- this.#pool = new pg.Pool({
30
- user: this.#config.user,
31
- password: this.#config.password,
32
- database: this.#config.database,
33
- host: this.#config.host,
34
- port: this.#config.port,
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,
35
37
  ...castTo({
36
- parseInputDatesAsUTC: true,
38
+ parseInputDatesAsUTC: true
37
39
  }),
38
- ...(this.#config.options || {})
40
+ ...this.config.options
39
41
  });
40
42
 
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
43
  try {
58
- const out = await pool.query(query, values);
59
- const records: T[] = [...out.rows].map(value => ({ ...value }));
60
- return { count: out.rowCount!, records };
44
+ await this.execute('CREATE EXTENSION IF NOT EXISTS pgcrypto;');
61
45
  } catch (error) {
62
- const code = error && typeof error === 'object' && 'code' in error ? error.code : undefined;
63
- switch (code) {
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;
46
+ if (!(error instanceof Error && error.message.includes('already exists'))) {
47
+ throw error;
69
48
  }
70
49
  }
50
+
51
+ ShutdownManager.signal.addEventListener('abort', () => this.pool.end());
71
52
  }
72
53
 
54
+ /**
55
+ * Acquires a client from the pool
56
+ */
73
57
  acquire(): Promise<PoolClient> {
74
- return this.#pool.connect();
58
+ return this.pool.connect();
59
+ }
60
+
61
+ /**
62
+ * Releases a client back to the pool
63
+ */
64
+ release(connection: PoolClient): void {
65
+ connection.release();
75
66
  }
76
67
 
77
- release(pool: PoolClient): void {
78
- pool.release();
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
+
84
+ try {
85
+ const result = await client.query(query, values);
86
+ const records: Type[] = [...result.rows].map(row => ({ ...row }));
87
+ return { count: result.rowCount ?? 0, records };
88
+ } catch (error) {
89
+ const code = error && typeof error === 'object' && 'code' in error ? castTo<Record<string, unknown>>(error).code : undefined;
90
+ switch (code) {
91
+ case '42P07':
92
+ throw new ExistsError('index', query);
93
+ case '23505':
94
+ throw new ExistsError('query', query);
95
+ default:
96
+ throw error;
97
+ }
98
+ }
79
99
  }
80
- }
100
+ }
package/src/dialect.ts CHANGED
@@ -1,145 +1,221 @@
1
- import type { SchemaFieldConfig } from '@travetto/schema';
2
- import { Injectable } from '@travetto/di';
3
- import type { AsyncContext } from '@travetto/context';
4
- import type { ModelType } from '@travetto/model';
5
- import { castTo, type Class } from '@travetto/runtime';
6
-
7
- import { SQLDialect, type SQLModelConfig, SQLModelUtil, type VisitStack, type SQLTableDescription } from '@travetto/model-sql';
8
-
9
- import { PostgreSQLConnection } from './connection.ts';
10
-
11
- /**
12
- * Postgresql Dialect for the SQL Model Source
13
- */
14
- @Injectable()
15
- export class PostgreSQLDialect extends SQLDialect {
16
-
17
- connection: PostgreSQLConnection;
18
-
19
- constructor(context: AsyncContext, config: SQLModelConfig) {
20
- super(config.namespace);
21
- this.connection = new PostgreSQLConnection(context, config);
22
- this.ID_AFFIX = '"';
23
-
24
- // Special operators
25
- Object.assign(this.SQL_OPS, {
26
- $regex: '~',
27
- $iregex: '~*'
28
- });
29
-
30
- // Special types
31
- Object.assign(this.COLUMN_TYPES, {
32
- JSON: 'json',
33
- TIMESTAMP: 'TIMESTAMP(6) WITH TIME ZONE'
34
- });
35
-
36
- // Word boundary
37
- this.regexWordBoundary = '\\y';
38
- }
39
-
40
- /**
41
- * How to hash
42
- */
43
- hash(value: string): string {
44
- return `encode(digest('${value}', 'sha1'), 'hex')`;
45
- }
46
-
47
- async describeTable(table: string): Promise<SQLTableDescription | undefined> {
48
- const IGNORE_FIELDS = [this.pathField.name, this.parentPathField.name, this.idxField.name].map(field => `'${field}'`);
49
-
50
- // 1. Columns
51
- const columns = await this.executeSQL<{ name: string, type: string, is_not_null: boolean }>(`
52
- SELECT
53
- a.attname AS name,
54
- pg_catalog.format_type(a.atttypid, a.atttypmod) AS type,
55
- a.attnotnull AS is_not_null
56
- FROM pg_catalog.pg_attribute a
57
- JOIN pg_catalog.pg_class c ON c.oid = a.attrelid
58
- JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
59
- LEFT JOIN
60
- pg_catalog.pg_attrdef ad ON ad.adrelid = c.oid AND ad.adnum = a.attnum
61
- WHERE
62
- c.relname = '${table}'
63
- AND a.attnum > 0
64
- AND NOT a.attisdropped
65
- AND a.attname NOT IN (${IGNORE_FIELDS.join(',')})
66
- ORDER BY
67
- a.attnum;
68
- `);
69
-
70
- if (!columns.count) {
71
- return undefined;
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}[]`;
72
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`;
149
+ }
150
+ return sqlPath;
151
+ }
152
+
153
+ shiftPlaceholders(sql: string, offset: number): string {
154
+ return sql.replaceAll(/[$](\d+)/g, (_, num) => `$${Number(num) + offset}`);
155
+ }
156
+
157
+ getTableExistsQuery(context: TableContext): { sql: string; parameters?: unknown[] } {
158
+ return {
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]
165
+ };
166
+ }
73
167
 
74
- // 2. Foreign Keys
75
- const foreignKeys = await this.executeSQL<{ name: string, from_column: string, to_column: string, to_table: string }>(`
76
- SELECT
77
- tc.constraint_name AS name,
78
- kcu.column_name AS from_column,
79
- ccu.column_name AS to_column,
80
- ccu.table_name AS to_table
81
- FROM information_schema.table_constraints AS tc
82
- JOIN information_schema.key_column_usage AS kcu
83
- ON tc.constraint_name = kcu.constraint_name
84
- JOIN information_schema.constraint_column_usage AS ccu
85
- ON ccu.constraint_name = tc.constraint_name
86
- WHERE tc.constraint_type = 'FOREIGN KEY'
87
- AND tc.table_name = '${table}'
88
- `);
89
-
90
- // 3. Indices
91
- const indices = await this.executeSQL<{ name: string, is_unique: boolean, columns: string[] }>(`
92
- SELECT
93
- i.relname AS name,
94
- ix.indisunique AS is_unique,
95
- ARRAY_AGG(a.attname || ' '|| CAST((o.OPTION & 1) AS VARCHAR) ORDER BY array_position(ix.indkey, a.attnum)) AS columns
96
- FROM pg_class t
97
- JOIN pg_index ix ON t.oid = ix.indrelid
98
- JOIN pg_class i ON i.oid = ix.indexrelid
99
- CROSS JOIN LATERAL UNNEST(ix.indkey) WITH ordinality AS c (colnum, ordinality)
100
- LEFT JOIN LATERAL UNNEST(ix.indoption) WITH ordinality AS o (OPTION, ordinality) ON c.ordinality = o.ordinality
101
- LEFT JOIN pg_catalog.pg_constraint co ON co.conindid = ix.indexrelid
102
- JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = c.colnum
103
- WHERE t.relname = '${table}'
104
- AND NOT ix.indisprimary
105
- AND co.conindid IS NULL
106
- GROUP BY i.relname, ix.indisunique
107
- `);
168
+ parseTableExistsResult(records: unknown[]): boolean {
169
+ return castTo<{ exists: boolean }>(records[0])?.exists ?? false;
170
+ }
108
171
 
172
+ getExistingColumnsQuery(context: TableContext): { sql: string; parameters?: unknown[] } {
109
173
  return {
110
- columns: columns.records.map(col => ({
111
- ...col,
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
- }))
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]
126
178
  };
127
179
  }
128
180
 
129
- /**
130
- * Define column modification
131
- */
132
- getModifyColumnSQL(stack: VisitStack[]): string {
133
- const field: SchemaFieldConfig = castTo(stack.at(-1));
134
- const type = this.getColumnType(field);
135
- const identifier = this.identifier(field.name);
136
- return `ALTER TABLE ${this.parentTable(stack)} ALTER COLUMN ${identifier} TYPE ${type} USING (${identifier}::${type});`;
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;`;
137
216
  }
138
217
 
139
- /**
140
- * Suppress foreign key checks
141
- */
142
- override getTruncateAllTablesSQL<T extends ModelType>(cls: Class<T>): string[] {
143
- return [`TRUNCATE ${this.table(SQLModelUtil.classToStack(cls))} CASCADE;`];
218
+ getTruncateTableSQL(context: TableContext): string {
219
+ return `TRUNCATE TABLE ${this.escapeIdentifier(context.tableName)} CASCADE;`;
144
220
  }
145
- }
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
+ }
@@ -12,4 +12,4 @@ export const service: ServiceDescriptor = {
12
12
  POSTGRES_PASSWORD: 'travetto',
13
13
  POSTGRES_DB: 'app'
14
14
  }
15
- };
15
+ };