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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md 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.26",
4
4
  "type": "module",
5
5
  "description": "PostgreSQL backing for the travetto model module, with real-time modeling support for SQL schemas.",
6
6
  "keywords": [
@@ -32,7 +32,7 @@
32
32
  "@travetto/context": "^8.0.0-alpha.20",
33
33
  "@travetto/model": "^8.0.0-alpha.23",
34
34
  "@travetto/model-query": "^8.0.0-alpha.24",
35
- "@travetto/model-sql": "^8.0.0-alpha.25",
35
+ "@travetto/model-sql": "^8.0.0-alpha.26",
36
36
  "@types/pg": "^8.20.0",
37
37
  "pg": "^8.22.0"
38
38
  },
package/src/config.ts ADDED
@@ -0,0 +1,50 @@
1
+ import type PG from 'pg';
2
+
3
+ import { Config } from '@travetto/config';
4
+ import { Runtime } from '@travetto/runtime';
5
+
6
+ /**
7
+ * PostgreSQL Model Configuration
8
+ */
9
+ @Config('model.postgres')
10
+ export class PostgresModelConfig {
11
+ /**
12
+ * Database host to connect to
13
+ */
14
+ host = '127.0.0.1';
15
+
16
+ /**
17
+ * Database port to connect to
18
+ */
19
+ port = 0;
20
+
21
+ /**
22
+ * Database username
23
+ */
24
+ user = Runtime.production ? '' : 'travetto';
25
+
26
+ /**
27
+ * Database password
28
+ */
29
+ password = Runtime.production ? '' : 'travetto';
30
+
31
+ /**
32
+ * Namespace/schema prefix for table names
33
+ */
34
+ namespace = '';
35
+
36
+ /**
37
+ * Database name
38
+ */
39
+ database = 'app';
40
+
41
+ /**
42
+ * Allow storage modifications (like table auto-creation and schema updates) at runtime
43
+ */
44
+ modifyStorage = !Runtime.production;
45
+
46
+ /**
47
+ * Client specific overrides
48
+ */
49
+ options?: PG.ClientConfig;
50
+ }
package/src/connection.ts CHANGED
@@ -1,66 +1,95 @@
1
1
  import { type Pool, type PoolClient, default as pg } from 'pg';
2
2
 
3
- import { type AsyncContext, WithAsyncContext } from '@travetto/context';
3
+ import type { AsyncContext } from '@travetto/context';
4
+ import { Injectable } from '@travetto/di';
4
5
  import { ExistsError } from '@travetto/model';
5
- import { Connection, type SQLModelConfig } from '@travetto/model-sql';
6
+ import { SQLConnection } from '@travetto/model-sql';
6
7
  import { castTo, ShutdownManager } from '@travetto/runtime';
7
8
 
9
+ import type { PostgresModelConfig } from './config.ts';
10
+ import { PostgresDialect } from './dialect.ts';
11
+
8
12
  /**
9
- * Connection support for postgresql
13
+ * PostgreSQL connection manager
10
14
  */
11
- export class PostgreSQLConnection extends Connection<PoolClient> {
12
- #pool: Pool;
13
- #config: SQLModelConfig;
15
+ @Injectable()
16
+ export class PostgresConnection extends SQLConnection<PoolClient> {
17
+ pool: Pool;
18
+
19
+ readonly dialect = new PostgresDialect();
20
+ readonly config: PostgresModelConfig;
14
21
 
15
- constructor(context: AsyncContext, config: SQLModelConfig) {
22
+ constructor(context: AsyncContext, config: PostgresModelConfig) {
16
23
  super(context);
17
- this.#config = config;
24
+ this.config = config;
18
25
  }
19
26
 
20
27
  /**
21
- * Initializes connection and establishes crypto extension for use with hashing
28
+ * Initializes the pool and creates the pgcrypto extension
22
29
  */
23
- @WithAsyncContext()
24
30
  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,
31
+ this.pool = new pg.Pool({
32
+ user: this.config.user,
33
+ password: this.config.password,
34
+ database: this.config.database,
35
+ host: this.config.host,
36
+ port: this.config.port,
31
37
  ...castTo({
32
38
  parseInputDatesAsUTC: true
33
39
  }),
34
- ...(this.#config.options || {})
40
+ ...this.config.options
35
41
  });
36
42
 
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
- );
43
+ try {
44
+ await this.execute('CREATE EXTENSION IF NOT EXISTS pgcrypto;');
45
+ } catch (error) {
46
+ if (!(error instanceof Error && error.message.includes('already exists'))) {
47
+ throw error;
48
+ }
49
+ }
50
+
51
+ ShutdownManager.signal.addEventListener('abort', () => this.pool.end());
52
+ }
46
53
 
47
- // Close postgres
48
- ShutdownManager.signal.addEventListener('abort', () => this.#pool.end());
54
+ /**
55
+ * Acquires a client from the pool
56
+ */
57
+ acquire(): Promise<PoolClient> {
58
+ return this.pool.connect();
49
59
  }
50
60
 
51
- async execute<T = unknown>(pool: PoolClient, query: string, values?: unknown[]): Promise<{ count: number; records: T[] }> {
52
- console.debug('Executing query', { query });
61
+ /**
62
+ * Releases a client back to the pool
63
+ */
64
+ release(connection: PoolClient): void {
65
+ connection.release();
66
+ }
67
+
68
+ /**
69
+ * Executes a query on the active client or pool directly
70
+ */
71
+ async execute<Type = unknown>(query: string, values?: unknown[]): Promise<{ count: number; records: Type[] }> {
72
+ console.debug('Executing PostgreSQL query', { query, values });
73
+
74
+ // Handle dynamically built savepoint names that cannot be parameterized in Postgres
75
+ if (query.includes('SAVEPOINT') || query.includes('ROLLBACK TO') || query.includes('RELEASE SAVEPOINT')) {
76
+ if (values && values.length > 0) {
77
+ query = query.replace('$1', `"${values[0]}"`);
78
+ values = [];
79
+ }
80
+ }
81
+
82
+ const client = this.active ?? this.pool;
83
+
53
84
  try {
54
- const out = await pool.query(query, values);
55
- const records: T[] = [...out.rows].map(value => ({ ...value }));
56
- return { count: out.rowCount!, records };
85
+ const result = await client.query(query, values);
86
+ const records: Type[] = [...result.rows].map(row => ({ ...row }));
87
+ return { count: result.rowCount ?? 0, records };
57
88
  } catch (error) {
58
- const code = error && typeof error === 'object' && 'code' in error ? error.code : undefined;
89
+ const code = error && typeof error === 'object' && 'code' in error ? castTo<Record<string, unknown>>(error).code : undefined;
59
90
  switch (code) {
60
- // Index already exists
61
91
  case '42P07':
62
92
  throw new ExistsError('index', query);
63
- // Unique violation
64
93
  case '23505':
65
94
  throw new ExistsError('query', query);
66
95
  default:
@@ -68,12 +97,4 @@ export class PostgreSQLConnection extends Connection<PoolClient> {
68
97
  }
69
98
  }
70
99
  }
71
-
72
- acquire(): Promise<PoolClient> {
73
- return this.#pool.connect();
74
- }
75
-
76
- release(pool: PoolClient): void {
77
- pool.release();
78
- }
79
100
  }
package/src/dialect.ts CHANGED
@@ -1,138 +1,221 @@
1
- import type { 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;
1
+ import { AbstractANSI99Dialect, type TableContext } from '@travetto/model-sql';
2
+ import { type Class, castTo, JSONUtil } from '@travetto/runtime';
3
+ import { type SchemaFieldConfig, SchemaRegistryIndex } from '@travetto/schema';
4
+
5
+ export class PostgresDialect extends AbstractANSI99Dialect {
6
+ returningSupport = true;
7
+ suggestLikeOperator = 'ILIKE';
8
+
9
+ getComplexColumnType(field: SchemaFieldConfig): string {
10
+ if (field.array && !SchemaRegistryIndex.has(field.type)) {
11
+ const scalarType = this.getColumnType(field);
12
+ return `${scalarType}[]`;
13
+ }
14
+ return 'JSONB';
15
+ }
16
+
17
+ getComplexColumnValue(field: SchemaFieldConfig, value: unknown): unknown {
18
+ if (field.array && !SchemaRegistryIndex.has(field.type)) {
19
+ return value ?? null;
20
+ }
21
+ return super.getComplexColumnValue(field, value);
22
+ }
23
+
24
+ getColumnType(fieldConfiguration: SchemaFieldConfig): string {
25
+ if (fieldConfiguration.type === castTo(BigInt)) {
26
+ return 'BIGINT';
27
+ }
28
+
29
+ if (fieldConfiguration.type === Number) {
30
+ if (fieldConfiguration.precision) {
31
+ const [digits, decimals] = fieldConfiguration.precision;
32
+ if (decimals) {
33
+ return `DECIMAL(${digits},${decimals})`;
34
+ }
35
+ if (digits < 5) {
36
+ return 'SMALLINT';
37
+ }
38
+ if (digits < 10) {
39
+ return 'INTEGER';
40
+ }
41
+ return 'BIGINT';
42
+ }
43
+ return 'INTEGER';
44
+ }
45
+
46
+ if (fieldConfiguration.type === Date) {
47
+ return 'TIMESTAMP(6) WITH TIME ZONE';
48
+ }
49
+
50
+ if (fieldConfiguration.type === Boolean) {
51
+ return 'BOOLEAN';
52
+ }
53
+
54
+ if (fieldConfiguration.type === String) {
55
+ if (fieldConfiguration.specifiers?.includes('text')) {
56
+ return 'TEXT';
57
+ }
58
+ return `VARCHAR(${fieldConfiguration.maxlength?.limit ?? 1024})`;
59
+ }
60
+
61
+ return 'JSONB';
62
+ }
63
+
64
+ compileJsonIndexPath(columnName: string, jsonPath: string[]): string {
65
+ const jsonAccessor = jsonPath
66
+ .slice(0, -1)
67
+ .map(segment => `->'${this.escapeLiteral(segment)}'`)
68
+ .join('');
69
+ const leafSegment = jsonPath[jsonPath.length - 1];
70
+ return `((${columnName}${jsonAccessor}->>'${this.escapeLiteral(leafSegment)}'))`;
71
+ }
72
+
73
+ override getPlaceholder(index: number): string {
74
+ return `$${index}`;
75
+ }
76
+
77
+ compileArrayAll(
78
+ sqlPath: string,
79
+ identifier: string,
80
+ value: unknown[],
81
+ field: SchemaFieldConfig,
82
+ topLevel?: boolean
83
+ ): { sql: string; formatted: unknown } {
84
+ if (topLevel && !SchemaRegistryIndex.has(field.type)) {
85
+ return { sql: `${sqlPath} @> ${identifier}`, formatted: value };
86
+ }
87
+ const jsonbPath = topLevel ? sqlPath : `(${sqlPath})::jsonb`;
88
+ return { sql: `${jsonbPath} @> ${identifier}::jsonb`, formatted: JSONUtil.toUTF8(value) };
89
+ }
90
+
91
+ compileArrayEquals(
92
+ sqlPath: string,
93
+ identifier: string,
94
+ values: unknown,
95
+ field: SchemaFieldConfig,
96
+ topLevel?: boolean
97
+ ): { sql: string; formatted: unknown } {
98
+ if (topLevel && !SchemaRegistryIndex.has(field.type)) {
99
+ if (Array.isArray(values)) {
100
+ return { sql: `${sqlPath} @> ${identifier}`, formatted: values };
101
+ }
102
+ return { sql: `${identifier} = ANY(${sqlPath})`, formatted: values };
103
+ }
104
+ const jsonbPath = topLevel ? sqlPath : `(${sqlPath})::jsonb`;
105
+ const val = Array.isArray(values) ? values : [values];
106
+ return { sql: `${jsonbPath} @> ${identifier}::jsonb`, formatted: JSONUtil.toUTF8(val) };
107
+ }
108
+
109
+ compileArrayAny(
110
+ sqlPath: string,
111
+ identifier: string,
112
+ values: unknown[],
113
+ field: SchemaFieldConfig,
114
+ topLevel?: boolean
115
+ ): { sql: string; formatted: unknown } {
116
+ if (topLevel && !SchemaRegistryIndex.has(field.type)) {
117
+ return { sql: `${sqlPath} && ${identifier}`, formatted: values };
118
+ }
119
+ const jsonbPath = topLevel ? sqlPath : `(${sqlPath})::jsonb`;
120
+ const formatted = values.map(v => JSONUtil.toUTF8(Array.isArray(v) ? v : [v]));
121
+ return { sql: `${jsonbPath} @> ANY(${identifier}::jsonb[])`, formatted };
122
+ }
123
+
124
+ compileArrayExists(sqlPath: string, identifier: string, field: SchemaFieldConfig, topLevel?: boolean): { sql: string } {
125
+ if (topLevel && !SchemaRegistryIndex.has(field.type)) {
126
+ return { sql: `(${sqlPath} IS NOT NULL AND cardinality(${sqlPath}) > 0)` };
127
+ }
128
+ const jsonbPath = topLevel ? sqlPath : `(${sqlPath})::jsonb`;
129
+ return { sql: `(${sqlPath} IS NOT NULL AND ${jsonbPath} <> '[]'::jsonb)` };
130
+ }
131
+
132
+ getRegexOperator(caseInsensitive: boolean): string {
133
+ return caseInsensitive ? '~*' : '~';
134
+ }
135
+
136
+ formatRegex(source: string, caseInsensitive: boolean): string {
137
+ return source.replaceAll('\\b', '\\y');
138
+ }
139
+
140
+ castColumn(sqlPath: string, type: Class): string {
141
+ if (type === Number) {
142
+ return `(${sqlPath})::NUMERIC`;
143
+ } else if (type === Boolean) {
144
+ return `(${sqlPath})::BOOLEAN`;
145
+ } else if (type === Date) {
146
+ return `(${sqlPath})::TIMESTAMP WITH TIME ZONE`;
147
+ } else if (type === String) {
148
+ return `(${sqlPath})::text`;
70
149
  }
150
+ return sqlPath;
151
+ }
71
152
 
72
- // 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
- `);
153
+ shiftPlaceholders(sql: string, offset: number): string {
154
+ return sql.replaceAll(/[$](\d+)/g, (_, num) => `$${Number(num) + offset}`);
155
+ }
106
156
 
157
+ getTableExistsQuery(context: TableContext): { sql: string; parameters?: unknown[] } {
107
158
  return {
108
- 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
- }))
159
+ sql: `SELECT EXISTS (
160
+ SELECT FROM pg_catalog.pg_class c
161
+ JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
162
+ WHERE c.relname = $1 AND c.relkind = 'r'
163
+ );`,
164
+ parameters: [context.tableName]
119
165
  };
120
166
  }
121
167
 
122
- /**
123
- * 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});`;
168
+ parseTableExistsResult(records: unknown[]): boolean {
169
+ return castTo<{ exists: boolean }>(records[0])?.exists ?? false;
170
+ }
171
+
172
+ getExistingColumnsQuery(context: TableContext): { sql: string; parameters?: unknown[] } {
173
+ return {
174
+ sql: `SELECT a.attname AS name, pg_catalog.format_type(a.atttypid, a.atttypmod) AS type
175
+ FROM pg_catalog.pg_attribute a
176
+ WHERE a.attrelid = $1::regclass AND a.attnum > 0 AND NOT a.attisdropped;`,
177
+ parameters: [context.tableName]
178
+ };
179
+ }
180
+
181
+ parseExistingColumns(records: unknown[]): Map<string, string> {
182
+ return new Map(castTo<{ name: string; type: string }[]>(records).map(record => [record.name, record.type.toUpperCase()]));
183
+ }
184
+
185
+ getAlterColumnTypeSQL(context: TableContext, columnName: string, columnType: string, existingType: string): string | undefined {
186
+ const normalizedExisting = existingType.replace('CHARACTER VARYING', 'VARCHAR').replace('INTEGER', 'INT');
187
+ const normalizedRequested = columnType.toUpperCase().replace('CHARACTER VARYING', 'VARCHAR').replace('INTEGER', 'INT');
188
+
189
+ if (!normalizedExisting.startsWith(normalizedRequested) && !normalizedRequested.startsWith(normalizedExisting)) {
190
+ return `ALTER TABLE ${this.escapeIdentifier(context.tableName)} ALTER COLUMN ${this.escapeIdentifier(columnName)} TYPE ${columnType} USING (${this.escapeIdentifier(columnName)}::${columnType});`;
191
+ }
192
+ return undefined;
193
+ }
194
+
195
+ getExistingIndexesQuery(context: TableContext): { sql: string; parameters?: unknown[] } {
196
+ return {
197
+ sql: `SELECT indexname, indexdef FROM pg_indexes WHERE tablename = $1;`,
198
+ parameters: [context.tableName]
199
+ };
200
+ }
201
+
202
+ parseExistingIndexes(records: unknown[]): Map<string, string> {
203
+ return new Map(
204
+ castTo<{ indexname: string; indexdef: string }[]>(records)
205
+ .filter(record => !record.indexname.endsWith('_pkey'))
206
+ .map(record => [record.indexname, record.indexdef])
207
+ );
208
+ }
209
+
210
+ getDropIndexSQL(context: TableContext, indexName: string): string {
211
+ return `DROP INDEX IF EXISTS ${this.escapeIdentifier(indexName)};`;
212
+ }
213
+
214
+ getDropTableSQL(context: TableContext): string {
215
+ return `DROP TABLE IF EXISTS ${this.escapeIdentifier(context.tableName)} CASCADE;`;
130
216
  }
131
217
 
132
- /**
133
- * Suppress foreign key checks
134
- */
135
- override getTruncateAllTablesSQL<T extends ModelType>(cls: Class<T>): string[] {
136
- return [`TRUNCATE ${this.table(SQLModelUtil.classToStack(cls))} CASCADE;`];
218
+ getTruncateTableSQL(context: TableContext): string {
219
+ return `TRUNCATE TABLE ${this.escapeIdentifier(context.tableName)} CASCADE;`;
137
220
  }
138
221
  }
package/src/service.ts ADDED
@@ -0,0 +1,28 @@
1
+ import type { default as pg } from 'pg';
2
+
3
+ import { Injectable, PostConstruct } from '@travetto/di';
4
+ import { BaseSQLModelService } from '@travetto/model-sql';
5
+
6
+ import type { PostgresConnection } from './connection.ts';
7
+
8
+ /**
9
+ * A PostgreSQL JSON-based document store model service
10
+ */
11
+ @Injectable()
12
+ export class PostgresModelService extends BaseSQLModelService {
13
+ connection: PostgresConnection;
14
+
15
+ constructor(connection: PostgresConnection) {
16
+ super();
17
+ this.connection = connection;
18
+ }
19
+
20
+ get client(): pg.Pool {
21
+ return this.connection.pool;
22
+ }
23
+
24
+ @PostConstruct()
25
+ override async initialize(): Promise<void> {
26
+ await super.initialize();
27
+ }
28
+ }