@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 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. 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#L32) 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)
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(ctx: AsyncContext, config: SQLModelConfig) {
41
- return new SQLModelService(ctx, config, new PostgreSQLDialect(ctx, config));
42
+ static getModelService(connection: PostgresConnection) {
43
+ return new PostgresModelService(connection);
42
44
  }
43
45
  }
44
46
  ```
45
47
 
46
- 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:
47
49
 
48
- **Code: Structure of SQLModelConfig**
50
+ **Code: Structure of PostgresModelConfig**
49
51
  ```typescript
50
- @Config('model.sql')
51
- export class SQLModelConfig<T extends {} = {}> {
52
+ @Config('model.postgres')
53
+ export class PostgresModelConfig {
52
54
  /**
53
- * Host to connect to
55
+ * Database host to connect to
54
56
  */
55
57
  host = '127.0.0.1';
58
+
56
59
  /**
57
- * Default port
60
+ * Database port to connect to
58
61
  */
59
62
  port = 0;
63
+
60
64
  /**
61
- * Username
65
+ * Database username
62
66
  */
63
67
  user = Runtime.production ? '' : 'travetto';
68
+
64
69
  /**
65
- * Password
70
+ * Database password
66
71
  */
67
72
  password = Runtime.production ? '' : 'travetto';
73
+
68
74
  /**
69
- * Table prefix
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 modification at runtime
78
- */
79
- modifyStorage?: boolean;
80
- /**
81
- * Db version
85
+ * Allow storage modifications (like table auto-creation and schema updates) at runtime
82
86
  */
83
- version = '';
87
+ modifyStorage = !Runtime.production;
88
+
84
89
  /**
85
- * Raw client options
90
+ * Client specific overrides
86
91
  */
87
- options: T = asFull({});
92
+ options?: PG.ClientConfig;
88
93
  }
89
94
  ```
90
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,43 +1,46 @@
1
1
  {
2
2
  "name": "@travetto/model-postgres",
3
- "version": "8.0.0-alpha.9",
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
- "email": "travetto.framework@gmail.com",
18
- "name": "Travetto Framework"
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
- "repository": {
27
- "url": "git+https://github.com/travetto/travetto.git",
28
- "directory": "module/model-postgres"
30
+ "publishConfig": {
31
+ "access": "public"
29
32
  },
30
33
  "dependencies": {
31
- "@travetto/config": "^8.0.0-alpha.9",
32
- "@travetto/context": "^8.0.0-alpha.9",
33
- "@travetto/model": "^8.0.0-alpha.9",
34
- "@travetto/model-query": "^8.0.0-alpha.9",
35
- "@travetto/model-sql": "^8.0.0-alpha.9",
36
- "@types/pg": "^8.18.0",
37
- "pg": "^8.20.0"
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.0-alpha.14"
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
- import { type AsyncContext, WithAsyncContext } from '@travetto/context';
5
- import { ExistsError } from '@travetto/model';
6
- import { type SQLModelConfig, Connection } from '@travetto/model-sql';
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
- * Connection support for postgresql
17
+ * PostgreSQL connection manager
10
18
  */
11
- export class PostgreSQLConnection extends Connection<PoolClient> {
19
+ @Injectable()
20
+ export class PostgresConnection extends SQLConnection<PoolClient> {
21
+ pool: Pool;
12
22
 
13
- #pool: Pool;
14
- #config: SQLModelConfig;
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.#config = config;
28
+ this.config = config;
22
29
  }
23
30
 
24
31
  /**
25
- * Initializes connection and establishes crypto extension for use with hashing
32
+ * Initializes the pool and creates the pgcrypto extension
26
33
  */
27
- @WithAsyncContext()
28
34
  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,
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
- ...(this.#config.options || {})
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
- const out = await pool.query(query, values);
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
- 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;
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.#pool.connect();
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
- release(pool: PoolClient): void {
78
- pool.release();
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 { 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;
72
- }
73
-
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
- `);
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
- 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
- }))
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
- * 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});`;
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
- * Suppress foreign key checks
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.1';
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
+ };