@travetto/model-postgres 8.0.0-alpha.25 → 8.0.0-alpha.27

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,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 [SQLModelService](https://github.com/travetto/travetto/tree/main/module/model-sql/src/service.ts#L69) 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
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 { PostgreSQLDialect } from '@travetto/model-postgres';
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(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/config.ts';
1
2
  export * from './src/connection.ts';
2
- export * from './src/dialect.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.25",
3
+ "version": "8.0.0-alpha.27",
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.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.25",
31
+ "@travetto/config": "^8.0.0-alpha.23",
32
+ "@travetto/context": "^8.0.0-alpha.21",
33
+ "@travetto/model": "^8.0.0-alpha.24",
34
+ "@travetto/model-query": "^8.0.0-alpha.25",
35
+ "@travetto/model-sql": "^8.0.0-alpha.27",
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.28"
40
+ "@travetto/cli": "^8.0.0-alpha.29"
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,79 +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, WithAsyncContext } from '@travetto/context';
4
- import { ExistsError } from '@travetto/model';
5
- import { Connection, type SQLModelConfig } from '@travetto/model-sql';
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';
6
7
  import { castTo, ShutdownManager } from '@travetto/runtime';
7
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
+ }
15
+
8
16
  /**
9
- * Connection support for postgresql
17
+ * PostgreSQL connection manager
10
18
  */
11
- export class PostgreSQLConnection extends Connection<PoolClient> {
12
- #pool: Pool;
13
- #config: SQLModelConfig;
19
+ @Injectable()
20
+ export class PostgresConnection extends SQLConnection<PoolClient> {
21
+ pool: Pool;
22
+
23
+ readonly dialect = new PostgresDialect();
24
+ readonly config: PostgresModelConfig;
14
25
 
15
- constructor(context: AsyncContext, config: SQLModelConfig) {
26
+ constructor(context: AsyncContext, config: PostgresModelConfig) {
16
27
  super(context);
17
- this.#config = config;
28
+ this.config = config;
18
29
  }
19
30
 
20
31
  /**
21
- * Initializes connection and establishes crypto extension for use with hashing
32
+ * Initializes the pool and creates the pgcrypto extension
22
33
  */
23
- @WithAsyncContext()
24
34
  async init(): Promise<void> {
25
- this.#pool = new pg.Pool({
26
- user: this.#config.user,
27
- password: this.#config.password,
28
- database: this.#config.database,
29
- host: this.#config.host,
30
- 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,
31
41
  ...castTo({
32
42
  parseInputDatesAsUTC: true
33
43
  }),
34
- ...(this.#config.options || {})
44
+ ...this.config.options
35
45
  });
36
46
 
37
- await this.runWithActive(() =>
38
- this.runWithTransaction('required', () =>
39
- this.execute(this.active!, 'CREATE EXTENSION IF NOT EXISTS pgcrypto;').catch(error => {
40
- if (!(error instanceof Error && error.message.includes('already exists'))) {
41
- throw error;
42
- }
43
- })
44
- )
45
- );
46
-
47
- // Close postgres
48
- ShutdownManager.signal.addEventListener('abort', () => this.#pool.end());
49
- }
50
-
51
- async execute<T = unknown>(pool: PoolClient, query: string, values?: unknown[]): Promise<{ count: number; records: T[] }> {
52
- console.debug('Executing query', { query });
53
47
  try {
54
- const out = await pool.query(query, values);
55
- const records: T[] = [...out.rows].map(value => ({ ...value }));
56
- return { count: out.rowCount!, records };
48
+ await this.execute('CREATE EXTENSION IF NOT EXISTS pgcrypto;');
57
49
  } catch (error) {
58
- const code = error && typeof error === 'object' && 'code' in error ? error.code : undefined;
59
- switch (code) {
60
- // Index already exists
61
- case '42P07':
62
- throw new ExistsError('index', query);
63
- // Unique violation
64
- case '23505':
65
- throw new ExistsError('query', query);
66
- default:
67
- throw error;
50
+ if (!(error instanceof Error && error.message.includes('already exists'))) {
51
+ throw error;
68
52
  }
69
53
  }
54
+
55
+ ShutdownManager.signal.addEventListener('abort', () => this.pool.end());
70
56
  }
71
57
 
58
+ /**
59
+ * Acquires a client from the pool
60
+ */
72
61
  acquire(): Promise<PoolClient> {
73
- return this.#pool.connect();
62
+ return this.pool.connect();
74
63
  }
75
64
 
76
- release(pool: PoolClient): void {
77
- pool.release();
65
+ /**
66
+ * Releases a client back to the pool
67
+ */
68
+ release(connection: PoolClient): void {
69
+ connection.release();
70
+ }
71
+
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
+ }
78
104
  }
79
105
  }
package/src/dialect.ts CHANGED
@@ -1,138 +1,255 @@
1
- import type { AsyncContext } from '@travetto/context';
2
- import { Injectable } from '@travetto/di';
3
- import type { ModelType } from '@travetto/model';
4
- import { SQLDialect, type SQLModelConfig, SQLModelUtil, type SQLTableDescription, type VisitStack } from '@travetto/model-sql';
5
- import { type Class, castTo } from '@travetto/runtime';
6
- import type { SchemaFieldConfig } from '@travetto/schema';
7
-
8
- import { PostgreSQLConnection } from './connection.ts';
9
-
10
- /**
11
- * Postgresql Dialect for the SQL Model Source
12
- */
13
- @Injectable()
14
- export class PostgreSQLDialect extends SQLDialect {
15
- connection: PostgreSQLConnection;
16
-
17
- constructor(context: AsyncContext, config: SQLModelConfig) {
18
- super(config.namespace);
19
- this.connection = new PostgreSQLConnection(context, config);
20
- this.ID_AFFIX = '"';
21
-
22
- // Special operators
23
- Object.assign(this.SQL_OPS, {
24
- $regex: '~',
25
- $iregex: '~*'
26
- });
27
-
28
- // Special types
29
- Object.assign(this.COLUMN_TYPES, {
30
- JSON: 'json',
31
- TIMESTAMP: 'TIMESTAMP(6) WITH TIME ZONE'
32
- });
33
-
34
- // Word boundary
35
- this.regexWordBoundary = '\\y';
36
- }
37
-
38
- /**
39
- * How to hash
40
- */
41
- hash(value: string): string {
42
- return `encode(digest('${value}', 'sha1'), 'hex')`;
43
- }
44
-
45
- async describeTable(table: string): Promise<SQLTableDescription | undefined> {
46
- const IGNORE_FIELDS = [this.pathField.name, this.parentPathField.name, this.idxField.name].map(field => `'${field}'`);
47
-
48
- // 1. Columns
49
- const columns = await this.executeSQL<{ name: string; type: string; is_not_null: boolean }>(`
50
- SELECT
51
- a.attname AS name,
52
- pg_catalog.format_type(a.atttypid, a.atttypmod) AS type,
53
- a.attnotnull AS is_not_null
54
- FROM pg_catalog.pg_attribute a
55
- JOIN pg_catalog.pg_class c ON c.oid = a.attrelid
56
- JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
57
- LEFT JOIN
58
- pg_catalog.pg_attrdef ad ON ad.adrelid = c.oid AND ad.adnum = a.attnum
59
- WHERE
60
- c.relname = '${table}'
61
- AND a.attnum > 0
62
- AND NOT a.attisdropped
63
- AND a.attname NOT IN (${IGNORE_FIELDS.join(',')})
64
- ORDER BY
65
- a.attnum;
66
- `);
67
-
68
- if (!columns.count) {
69
- return undefined;
70
- }
71
-
72
- // 2. Foreign Keys
73
- const foreignKeys = await this.executeSQL<{ name: string; from_column: string; to_column: string; to_table: string }>(`
74
- SELECT
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
- `);
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';
106
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
+ #buildContainmentPayload(value: unknown, context?: ResolvedPathContext): unknown {
78
+ if (!context?.subPath || context.subPath.length === 0) {
79
+ return Array.isArray(value) ? value : [value];
80
+ }
81
+
82
+ let currentPayload: unknown;
83
+ if (Array.isArray(value)) {
84
+ currentPayload = value.map(item => {
85
+ let itemPayload: unknown = item;
86
+ for (let index = context.subPath!.length - 1; index >= 0; index--) {
87
+ itemPayload = { [context.subPath![index]]: itemPayload };
88
+ }
89
+ return itemPayload;
90
+ });
91
+ } else {
92
+ currentPayload = value;
93
+ for (let index = context.subPath.length - 1; index >= 0; index--) {
94
+ currentPayload = { [context.subPath[index]]: currentPayload };
95
+ }
96
+ currentPayload = [currentPayload];
97
+ }
98
+
99
+ if (context.arrayPath && context.arrayPath.length > 1) {
100
+ for (let index = context.arrayPath.length - 1; index >= 1; index--) {
101
+ currentPayload = { [context.arrayPath[index]]: currentPayload };
102
+ }
103
+ return currentPayload;
104
+ }
105
+
106
+ return currentPayload;
107
+ }
108
+
109
+ #getPostgresArrayTarget(context: ResolvedPathContext): {
110
+ isNative: boolean;
111
+ sqlPath: string;
112
+ jsonbPath: string;
113
+ buildPayload: (val: unknown) => unknown;
114
+ } {
115
+ const arrayField = context.arrayField ?? context.leafField;
116
+ const isTopLevel = (context.arrayPath?.length ?? 1) === 1;
117
+ const hasSubPath = (context.subPath?.length ?? 0) > 0;
118
+ const isScalarArray = arrayField ? !SchemaRegistryIndex.has(arrayField.type) : true;
119
+ const isNative = isTopLevel && !hasSubPath && isScalarArray;
120
+
121
+ const targetPath =
122
+ hasSubPath && context.arrayPath && context.arrayPath.length > 0 ? this.escapeIdentifier(context.arrayPath[0]) : context.sqlPath;
123
+
124
+ const jsonbPath = isTopLevel && !hasSubPath ? targetPath : `(${targetPath})::jsonb`;
125
+ const buildPayload = (value: unknown): unknown => this.#buildContainmentPayload(value, context);
126
+
127
+ return { isNative, sqlPath: context.sqlPath, jsonbPath, buildPayload };
128
+ }
129
+
130
+ compileArrayAll(context: ResolvedPathContext, identifier: string, value: unknown[]): { sql: string; formatted: unknown } {
131
+ const target = this.#getPostgresArrayTarget(context);
132
+ if (target.isNative) {
133
+ return { sql: `${target.sqlPath} @> ${identifier}`, formatted: value };
134
+ }
135
+ return { sql: `${target.jsonbPath} @> ${identifier}::jsonb`, formatted: JSONUtil.toUTF8(target.buildPayload(value)) };
136
+ }
137
+
138
+ compileArrayEquals(context: ResolvedPathContext, identifier: string, values: unknown): { sql: string; formatted: unknown } {
139
+ const target = this.#getPostgresArrayTarget(context);
140
+ if (target.isNative) {
141
+ if (Array.isArray(values)) {
142
+ return { sql: `${target.sqlPath} @> ${identifier}`, formatted: values };
143
+ }
144
+ return { sql: `${identifier} = ANY(${target.sqlPath})`, formatted: values };
145
+ }
146
+ return { sql: `${target.jsonbPath} @> ${identifier}::jsonb`, formatted: JSONUtil.toUTF8(target.buildPayload(values)) };
147
+ }
148
+
149
+ compileArrayAny(context: ResolvedPathContext, identifier: string, values: unknown[]): { sql: string; formatted: unknown } {
150
+ const target = this.#getPostgresArrayTarget(context);
151
+ if (target.isNative) {
152
+ return { sql: `${target.sqlPath} && ${identifier}`, formatted: values };
153
+ }
154
+ const formatted = values.map(v => JSONUtil.toUTF8(target.buildPayload(v)));
155
+ return { sql: `${target.jsonbPath} @> ANY(${identifier}::jsonb[])`, formatted };
156
+ }
157
+
158
+ compileArrayExists(context: ResolvedPathContext, identifier?: string): { sql: string } {
159
+ const target = this.#getPostgresArrayTarget(context);
160
+ if (target.isNative) {
161
+ return { sql: `(${target.sqlPath} IS NOT NULL AND cardinality(${target.sqlPath}) > 0)` };
162
+ }
163
+ return { sql: `(${target.jsonbPath} IS NOT NULL AND ${target.jsonbPath} <> '[]'::jsonb)` };
164
+ }
165
+
166
+ getRegexOperator(caseInsensitive: boolean): string {
167
+ return caseInsensitive ? '~*' : '~';
168
+ }
169
+
170
+ formatRegex(source: string, caseInsensitive: boolean): string {
171
+ return source.replaceAll('\\b', '\\y');
172
+ }
173
+
174
+ castColumn(sqlPath: string, type: Class): string {
175
+ if (type === Number) {
176
+ return `(${sqlPath})::NUMERIC`;
177
+ } else if (type === Boolean) {
178
+ return `(${sqlPath})::BOOLEAN`;
179
+ } else if (type === Date) {
180
+ return `(${sqlPath})::TIMESTAMP WITH TIME ZONE`;
181
+ } else if (type === String) {
182
+ return `(${sqlPath})::text`;
183
+ }
184
+ return sqlPath;
185
+ }
186
+
187
+ shiftPlaceholders(sql: string, offset: number): string {
188
+ return sql.replaceAll(/[$](\d+)/g, (_, num) => `$${Number(num) + offset}`);
189
+ }
190
+
191
+ getTableExistsQuery(context: TableContext): { sql: string; parameters?: unknown[] } {
192
+ return {
193
+ sql: `SELECT EXISTS (
194
+ SELECT FROM pg_catalog.pg_class c
195
+ JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
196
+ WHERE c.relname = $1 AND c.relkind = 'r'
197
+ );`,
198
+ parameters: [context.tableName]
199
+ };
200
+ }
201
+
202
+ parseTableExistsResult(records: unknown[]): boolean {
203
+ return castTo<{ exists: boolean }>(records[0])?.exists ?? false;
204
+ }
205
+
206
+ getExistingColumnsQuery(context: TableContext): { sql: string; parameters?: unknown[] } {
107
207
  return {
108
- columns: columns.records.map(col => ({
109
- ...col,
110
- type: col.type.toUpperCase().replace('CHARACTER VARYING', 'VARCHAR').replace('INTEGER', 'INT'),
111
- is_not_null: !!col.is_not_null
112
- })),
113
- foreignKeys: foreignKeys.records,
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
- }))
208
+ sql: `SELECT a.attname AS name, pg_catalog.format_type(a.atttypid, a.atttypmod) AS type
209
+ FROM pg_catalog.pg_attribute a
210
+ WHERE a.attrelid = $1::regclass AND a.attnum > 0 AND NOT a.attisdropped;`,
211
+ parameters: [context.tableName]
119
212
  };
120
213
  }
121
214
 
122
- /**
123
- * Define column modification
124
- */
125
- getModifyColumnSQL(stack: VisitStack[]): string {
126
- const field: SchemaFieldConfig = castTo(stack.at(-1));
127
- const type = this.getColumnType(field);
128
- const identifier = this.identifier(field.name);
129
- return `ALTER TABLE ${this.parentTable(stack)} ALTER COLUMN ${identifier} TYPE ${type} USING (${identifier}::${type});`;
215
+ parseExistingColumns(records: unknown[]): Map<string, string> {
216
+ return new Map(castTo<{ name: string; type: string }[]>(records).map(record => [record.name, record.type.toUpperCase()]));
217
+ }
218
+
219
+ getAlterColumnTypeSQL(context: TableContext, columnName: string, columnType: string, existingType: string): string | undefined {
220
+ const normalizedExisting = existingType.replace('CHARACTER VARYING', 'VARCHAR').replace('INTEGER', 'INT');
221
+ const normalizedRequested = columnType.toUpperCase().replace('CHARACTER VARYING', 'VARCHAR').replace('INTEGER', 'INT');
222
+
223
+ if (!normalizedExisting.startsWith(normalizedRequested) && !normalizedRequested.startsWith(normalizedExisting)) {
224
+ return `ALTER TABLE ${this.escapeIdentifier(context.tableName)} ALTER COLUMN ${this.escapeIdentifier(columnName)} TYPE ${columnType} USING (${this.escapeIdentifier(columnName)}::${columnType});`;
225
+ }
226
+ return undefined;
227
+ }
228
+
229
+ getExistingIndexesQuery(context: TableContext): { sql: string; parameters?: unknown[] } {
230
+ return {
231
+ sql: `SELECT indexname, indexdef FROM pg_indexes WHERE tablename = $1;`,
232
+ parameters: [context.tableName]
233
+ };
234
+ }
235
+
236
+ parseExistingIndexes(records: unknown[]): Map<string, string> {
237
+ return new Map(
238
+ castTo<{ indexname: string; indexdef: string }[]>(records)
239
+ .filter(record => !record.indexname.endsWith('_pkey'))
240
+ .map(record => [record.indexname, record.indexdef])
241
+ );
242
+ }
243
+
244
+ getDropIndexSQL(context: TableContext, indexName: string): string {
245
+ return `DROP INDEX IF EXISTS ${this.escapeIdentifier(indexName)};`;
246
+ }
247
+
248
+ getDropTableSQL(context: TableContext): string {
249
+ return `DROP TABLE IF EXISTS ${this.escapeIdentifier(context.tableName)} CASCADE;`;
130
250
  }
131
251
 
132
- /**
133
- * Suppress foreign key checks
134
- */
135
- override getTruncateAllTablesSQL<T extends ModelType>(cls: Class<T>): string[] {
136
- return [`TRUNCATE ${this.table(SQLModelUtil.classToStack(cls))} CASCADE;`];
252
+ getTruncateTableSQL(context: TableContext): string {
253
+ return `TRUNCATE TABLE ${this.escapeIdentifier(context.tableName)} CASCADE;`;
137
254
  }
138
255
  }
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
+ }