UNPKG

@decaf-ts/for-postgres

Version:
1,242 lines (1,229 loc) 252 kB
import { __decorate, __metadata } from 'tslib'; import { Paginator, QueryError, Statement, Repository, OrderDirection, GroupOperator, Operator, Sequence, PersistenceKeys, Dispatch, final, Adapter, ConnectionError, DefaultSequenceOptions } from '@decaf-ts/core'; import { BaseError, findPrimaryKey, InternalError, NotFoundError, DefaultSeparator, Context, OperationKeys, enforceDBDecorators, ValidationError, ConflictError, DBKeys, readonly, onCreate } from '@decaf-ts/db-decorators'; import 'reflect-metadata'; import { Model, ValidationKeys, ModelKeys, DEFAULT_ERROR_MESSAGES, Decoration, required, propMetadata } from '@decaf-ts/decorator-validation'; import { Pool } from 'pg'; import { Reflection } from '@decaf-ts/reflection'; import { Logging } from '@decaf-ts/logging'; /** * @description Regular expression to identify reserved attributes in PostgreSQL * @summary Matches any attribute that is a PostgreSQL reserved keyword * @const reservedAttributes * @memberOf module:for-postgres */ const reservedAttributes = /^(select|from|where|and|or|insert|update|delete|drop|create|table|index|primary|key|foreign|references|constraint|unique|check|default|null|not|as|order|by|group|having|limit|offset|join|inner|outer|left|right|full|on|using|values|returning|set|into|case|when|then|else|end|cast|coalesce|exists|any|all|some|in|between|like|ilike|similar|to|is|true|false|asc|desc|distinct|union|intersect|except|natural|lateral|window|over|partition|range|rows|unbounded|preceding|following|current|row|with|recursive|materialized|view|function|trigger|procedure|language|returns|return|declare|begin|commit|rollback|savepoint|transaction|temporary|temp|if|loop|while|for|continue|exit|raise|exception|notice|info|log|debug|assert|execute|perform|get|diagnostics|call|do|alias|comment|vacuum|analyze|explain|copy|grant|revoke|privileges|public|usage|schema|sequence|owned|owner|tablespace|storage|inherits|type|operator|collate|collation|cascade|restrict|add|alter|column|rename|to|enable|disable|force|no|instead|of|before|after|each|statement|row|execute|also|only|exclude|nulls|others|ordinality|ties|nothing|cache|cycle|increment|minvalue|maxvalue|start|restart|by|called|returns|language|immutable|stable|volatile|strict|security|definer|invoker|cost|rows|support|handler|inline|validator|options|storage|inheritance|oids|without|data|dictionary|encoding|lc_collate|lc_ctype|connection|limit|password|valid|until|superuser|nosuperuser|createdb|nocreatedb|createrole|nocreaterole|inherit|noinherit|login|nologin|replication|noreplication|bypassrls|nobypassrls|encrypted|unencrypted|new|old|session_user|current_user|current_role|current_schema|current_catalog|current_date|current_time|current_timestamp|localtime|localtimestamp|current_database|inet|cidr|macaddr|macaddr8|bit|varbit|tsvector|tsquery|uuid|xml|json|jsonb|int|integer|smallint|bigint|decimal|numeric|real|double|precision|float|boolean|bool|char|character|varchar|text|bytea|date|time|timestamp|interval|point|line|lseg|box|path|polygon|circle|money|void)$/i; const PostgresFlavour = "postgres"; /** * @description Key constants used in PostgreSQL operations * @summary Collection of string constants for PostgreSQL database properties and operations * @const PostgresKeys * @type {PostgrsKeysType} * @memberOf module:for-postgres */ const PostgresKeys = { SEPARATOR: ".", ID: "id", VERSION: "version", DELETED: "deleted_at", TABLE: "table_name", SCHEMA: "schema_name", SEQUENCE: "sequence_name", INDEX: "index", }; /** * @description Error thrown when there is an issue with CouchDB indexes * @summary Represents an error related to CouchDB index operations * @param {string|Error} msg - The error message or Error object * @class * @category Errors * @example * // Example of using IndexError * try { * // Some code that might throw an index error * throw new IndexError("Index not found"); * } catch (error) { * if (error instanceof IndexError) { * console.error("Index error occurred:", error.message); * } * } */ class IndexError extends BaseError { constructor(msg) { super(IndexError.name, msg, 404); } } /** * @description SQL operators available in PostgreSQL queries * @summary Enum of standard SQL operators that can be used in PostgreSQL queries * @enum {string} * @memberOf module:for-postgres */ var SQLOperator; (function (SQLOperator) { SQLOperator["EQUAL"] = "="; SQLOperator["NOT_EQUAL"] = "<>"; SQLOperator["LESS_THAN"] = "<"; SQLOperator["LESS_THAN_OR_EQUAL"] = "<="; SQLOperator["GREATER_THAN"] = ">"; SQLOperator["GREATER_THAN_OR_EQUAL"] = ">="; SQLOperator["IN"] = "IN"; SQLOperator["NOT_IN"] = "NOT IN"; SQLOperator["LIKE"] = "LIKE"; SQLOperator["ILIKE"] = "ILIKE"; SQLOperator["BETWEEN"] = "BETWEEN"; SQLOperator["IS_NULL"] = "IS NULL"; SQLOperator["IS_NOT_NULL"] = "IS NOT NULL"; SQLOperator["EXISTS"] = "EXISTS"; SQLOperator["NOT_EXISTS"] = "NOT EXISTS"; SQLOperator["ANY"] = "ANY"; SQLOperator["ALL"] = "ALL"; SQLOperator["SOME"] = "SOME"; })(SQLOperator || (SQLOperator = {})); /** * @description Default query limit for PostgreSQL queries * @summary Maximum number of rows to return in a single query * @const PostgreSQLQueryLimit * @memberOf module:for-postgres */ const PostgreSQLQueryLimit = 250; /** * @description Mapping of operator names to PostgreSQL SQL operators * @summary Constants for PostgreSQL comparison operators used in SQL queries * @typedef {Object} PostgreSQLOperatorType * @property {string} EQUAL - Equality operator (=) * @property {string} DIFFERENT - Inequality operator (<>) * @property {string} BIGGER - Greater than operator (>) * @property {string} BIGGER_EQ - Greater than or equal operator (>=) * @property {string} SMALLER - Less than operator (<) * @property {string} SMALLER_EQ - Less than or equal operator (<=) * @property {string} NOT - Negation operator (NOT) * @property {string} IN - In array operator (IN) * @property {string} REGEXP - Regular expression operator (~) * @property {string} IREGEXP - Case-insensitive regular expression operator (~*) * @property {string} LIKE - Pattern matching operator (LIKE) * @property {string} ILIKE - Case-insensitive pattern matching operator (ILIKE) * @property {string} BETWEEN - Range operator (BETWEEN) * @property {string} IS_NULL - NULL check operator (IS NULL) * @property {string} IS_NOT_NULL - NOT NULL check operator (IS NOT NULL) * @const PostgreSQLOperator * @type {PostgreSQLOperatorType} * @memberOf module:for-postgres */ const PostgreSQLOperator = { EQUAL: SQLOperator.EQUAL, DIFFERENT: SQLOperator.NOT_EQUAL, BIGGER: SQLOperator.GREATER_THAN, BIGGER_EQ: SQLOperator.GREATER_THAN_OR_EQUAL, SMALLER: SQLOperator.LESS_THAN, SMALLER_EQ: SQLOperator.LESS_THAN_OR_EQUAL, BETWEEN: SQLOperator.BETWEEN, NOT: "NOT", IN: SQLOperator.IN, IS_NULL: SQLOperator.IS_NULL, IS_NOT_NULL: SQLOperator.IS_NOT_NULL, REGEXP: "~", IREGEXP: "~*", LIKE: SQLOperator.LIKE, ILIKE: SQLOperator.ILIKE, }; /** * @description Mapping of logical operator names to PostgreSQL SQL operators * @summary Constants for PostgreSQL logical operators used in SQL queries * @typedef {Object} PostgreSQLGroupOperatorType * @property {string} AND - Logical AND operator (AND) * @property {string} OR - Logical OR operator (OR) * @const PostgreSQLGroupOperator * @type {PostgreSQLGroupOperatorType} * @memberOf module:for-postgres */ const PostgreSQLGroupOperator = { AND: "AND", OR: "OR", }; /** * @description Special constant values used in PostgreSQL queries * @summary String constants representing special values in PostgreSQL * @typedef {Object} PostgreSQLConstType * @property {string} NULL - String representation of null value * @const PostgreSQLConst * @type {PostgreSQLConstType} * @memberOf module:for-postgres */ const PostgreSQLConst = { NULL: "NULL", }; /** * @description Paginator for PostgreSQL query results * @summary Implements pagination for PostgreSQL queries using LIMIT and OFFSET for efficient navigation through result sets * @template M - The model type that extends Model * @template R - The result type * @param {PostgresAdapter<any, any, any>} adapter - The PostgreSQL adapter * @param {PostgresQuery} query - The PostgresSQL query to paginate * @param {number} size - The page size * @param {Constructor<M>} clazz - The model constructor * @class PostgresPaginator * @example * // Example of using PostgreSQLPaginator * const adapter = new MyPostgreSQLAdapter(pool); * const query = { table: "users" }; * const paginator = new PostgreSQLPaginator(adapter, query, 10, User); * * // Get the first page * const page1 = await paginator.page(1); * * // Get the next page * const page2 = await paginator.page(2); */ class PostgresPaginator extends Paginator { /** * @description Gets the total number of pages * @summary Returns the total number of pages based on the record count and page size * @return {number} The total number of pages */ get total() { return this._totalPages; } /** * @description Gets the total record count * @summary Returns the total number of records matching the query * @return {number} The total record count */ get count() { return this._recordCount; } /** * @description Creates a new PostgreSQLPaginator instance * @summary Initializes a paginator for PostgreSQL query results * @param {PostgresAdapter} adapter - The PostgreSQL adapter * @param {PostgreSQLQuery} query - The PostgreSQL query to paginate * @param {number} size - The page size * @param {Constructor<M>} clazz - The model constructor */ constructor(adapter, query, size, clazz) { super(adapter, query, size, clazz); } /** * @description Prepares a query for pagination * @summary Modifies the raw query to include pagination parameters * @param {PostgresQuery} rawStatement - The original PostgreSQL query * @return {PostgresQuery} The prepared query with pagination parameters */ prepare(rawStatement) { const query = { ...rawStatement }; return query; } /** * @description Retrieves a specific page of results * @summary Executes the query with pagination and processes the results * @param {number} [page=1] - The page number to retrieve * @return {Promise<R[]>} A promise that resolves to an array of results * @throws {PagingError} If trying to access an invalid page or if no class is defined * @mermaid * sequenceDiagram * participant Client * participant PostgreSQLPaginator * participant Adapter * participant PostgreSQL * * Client->>PostgreSQLPaginator: page(pageNumber) * Note over PostgreSQLPaginator: Clone statement * * alt First time or need count * PostgreSQLPaginator->>Adapter: Get total count * Adapter->>PostgreSQL: Execute COUNT query * PostgreSQL-->>Adapter: Return count * Adapter-->>PostgreSQLPaginator: Return count * PostgreSQLPaginator->>PostgreSQLPaginator: Calculate total pages * end * * PostgreSQLPaginator->>PostgreSQLPaginator: validatePage(page) * PostgreSQLPaginator->>PostgreSQLPaginator: Calculate offset * PostgreSQLPaginator->>PostgreSQLPaginator: Add limit and offset to query * * PostgreSQLPaginator->>Adapter: raw(statement, false) * Adapter->>PostgreSQL: Execute query * PostgreSQL-->>Adapter: Return results * Adapter-->>PostgreSQLPaginator: Return PostgreSQLResponse * * Note over PostgreSQLPaginator: Process results * * PostgreSQLPaginator->>PostgreSQLPaginator: Check for clazz * * alt No clazz * PostgreSQLPaginator-->>Client: Throw PagingError * else Has clazz * PostgreSQLPaginator->>PostgreSQLPaginator: Find primary key * * alt Has columns in statement * PostgreSQLPaginator->>PostgreSQLPaginator: Use rows directly * else No columns * PostgreSQLPaginator->>PostgreSQLPaginator: Process each row * loop For each row * PostgreSQLPaginator->>Adapter: revert(row, clazz, pkDef.id, id) * end * end * * PostgreSQLPaginator->>PostgreSQLPaginator: Update currentPage * PostgreSQLPaginator-->>Client: Return results * end */ async page(page = 1) { throw new Error(`Not implemented yet`); // const statement = { ...this.statement }; // // // Get total count if not already calculated // if (!this._recordCount || !this._totalPages) { // this._totalPages = this._recordCount = 0; // // // Create a count query based on the original query // const countQuery: PostgresQuery = { // ...statement, // count: true, // limit: undefined, // offset: undefined, // }; // // const countResult: QueryResult = await this.adapter.raw( // countQuery, // false // ); // this._recordCount = parseInt(countResult.rows[0]?.count || "0", 10); // // if (this._recordCount > 0) { // const size = statement?.limit || this.size; // this._totalPages = Math.ceil(this._recordCount / size); // } // } // // this.validatePage(page); // // // Calculate offset based on page number // const offset = (page - 1) * this.size; // statement.limit = this.size; // statement.offset = offset; // // const result: PostgreSQLResponse<any> = await this.adapter.raw( // statement, // false // ); // // if (!this.clazz) throw new PagingError("No statement target defined"); // // const pkDef = findPrimaryKey(new this.clazz()); // const rows = result.rows || []; // // const results = // statement.columns && statement.columns.length // ? rows // has columns means it's not full model // : rows.map((row: any) => { // return this.adapter.revert( // row, // this.clazz, // pkDef.id, // Sequence.parseValue(pkDef.props.type, row[PostgreSQLKeys.ID]) // ); // }); // // this._currentPage = page; // return results as R[]; } } /** * @description Translates core operators to PostgreSQL SQL operators * @summary Converts Decaf.ts core operators to their equivalent PostgreSQL SQL operators * @param {GroupOperator | Operator} operator - The core operator to translate * @return {SQLOperator | string} The equivalent PostgreSQL SQL operator * @throws {QueryError} If no translation exists for the given operator * @function translateOperators * @memberOf module:for-postgres * @mermaid * sequenceDiagram * participant Caller * participant translateOperators * participant PostgreSQLOperator * participant PostgreSQLGroupOperator * * Caller->>translateOperators: operator * * translateOperators->>PostgreSQLOperator: Check for match * alt Found in PostgreSQLOperator * PostgreSQLOperator-->>translateOperators: Return matching operator * translateOperators-->>Caller: Return SQLOperator * else Not found * translateOperators->>PostgreSQLGroupOperator: Check for match * alt Found in PostgreSQLGroupOperator * PostgreSQLGroupOperator-->>translateOperators: Return matching operator * translateOperators-->>Caller: Return string * else Not found * translateOperators-->>Caller: Throw QueryError * end * end */ function translateOperators(operator) { for (const operators of [PostgreSQLOperator, PostgreSQLGroupOperator]) { const el = Object.keys(operators).find((k) => k === operator); if (el) return operators[el]; } throw new QueryError(`Could not find adapter translation for operator ${operator}`); } /** * @description Statement builder for PostgreSQL queries * @summary Provides a fluent interface for building PostgreSQL queries with type safety * @template M - The model type that extends Model * @template R - The result type * @param adapter - The PostgreSQL adapter * @class PostgresStatement * @example * // Example of using PostgreSQLStatement * const adapter = new MyPostgreSQLAdapter(pool); * const statement = new PostgreSQLStatement<User, User[]>(adapter); * * // Build a query * const users = await statement * .from(User) * .where(Condition.attribute<User>('age').gt(18)) * .orderBy('lastName', 'asc') * .limit(10) * .execute(); */ class PostgresStatement extends Statement { constructor(adapter) { super(adapter); } /** * @description Builds a PostgreSQL query from the statement * @summary Converts the statement's conditions, selectors, and options into a PostgreSQL query * @return {PostgresQuery} The built PostgreSQL query * @throws {Error} If there are invalid query conditions * @mermaid * sequenceDiagram * participant Statement * participant Repository * participant parseCondition * * Statement->>Statement: build() * Note over Statement: Initialize query * Statement->>Repository: Get table name * Repository-->>Statement: Return table name * Statement->>Statement: Create base query * * alt Has selectSelector * Statement->>Statement: Add columns to query * end * * alt Has whereCondition * Statement->>Statement: Create combined condition with table * Statement->>parseCondition: Parse condition * parseCondition-->>Statement: Return parsed conditions * Statement->>Statement: Add conditions to query * end * * alt Has orderBySelector * Statement->>Statement: Add orderBy to query * end * * alt Has limitSelector * Statement->>Statement: Set limit * else * Statement->>Statement: Use default limit * end * * alt Has offsetSelector * Statement->>Statement: Set offset * end * * Statement-->>Statement: Return query */ build() { const tableName = Repository.table(this.fromSelector); const m = new this.fromSelector(); const q = [ `SELECT ${this.selectSelector ? this.selectSelector.map((k) => `${k.toString()}`).join(", ") : "*"} from ${tableName}`, ]; const values = []; if (this.whereCondition) { const parsed = this.parseCondition(this.whereCondition, tableName); const { query } = parsed; q.push(` WHERE ${query}`); values.push(...parsed.values); } if (!this.orderBySelector) this.orderBySelector = [findPrimaryKey(m).id, OrderDirection.ASC]; q.push(` ORDER BY ${tableName}.${this.orderBySelector[0]} ${this.orderBySelector[1].toUpperCase()}`); if (this.limitSelector) { q.push(` LIMIT ${this.limitSelector}`); } else { console.warn(`No limit selector defined. Using default limit of ${PostgreSQLQueryLimit}`); q.push(` LIMIT ${PostgreSQLQueryLimit}`); } // Add offset if (this.offsetSelector) q.push(` OFFSET ${this.offsetSelector}`); q.push(";"); return { query: q.join(""), values: values, }; } /** * @description Creates a paginator for the statement * @summary Builds the query and returns a PostgreSQLPaginator for paginated results * @template R - The result type * @param {number} size - The page size * @return {Promise<Paginator<M, R, PostgreSQLQuery>>} A promise that resolves to a paginator * @throws {InternalError} If there's an error building the query */ async paginate(size) { try { const query = this.build(); return new PostgresPaginator(this.adapter, query, size, this.fromSelector); } catch (e) { throw new InternalError(e); } } /** * @description Processes a record from PostgreSQL * @summary Converts a raw PostgreSQL record to a model instance * @param {any} r - The raw record from PostgreSQL * @param pkAttr - The primary key attribute of the model * @param {"Number" | "BigInt" | undefined} sequenceType - The type of the sequence * @return {any} The processed record */ processRecord(r, pkAttr) { if (typeof r[pkAttr] !== "undefined") { return this.adapter.revert(r, this.fromSelector, pkAttr, r[pkAttr]); } return r; } /** * @description Executes a raw PostgreSQL query * @summary Sends a raw PostgreSQL query to the database and processes the results * @template R - The result type * @param {PostgresQuery} rawInput - The raw PostgreSQL query to execute * @return {Promise<R>} A promise that resolves to the query results */ async raw(rawInput) { const results = await this.adapter.raw(rawInput, true); const pkDef = findPrimaryKey(new this.fromSelector()); const pkAttr = pkDef.id; if (!this.selectSelector) return results.map((r) => this.processRecord(r, pkAttr)); return results; } /** * @description Parses a condition into PostgreSQL conditions * @summary Converts a Condition object into PostgreSQL condition structures * @param {Condition<M>} condition - The condition to parse * @param {string} [tableName] - the positional index of the arguments * @return {PostgresQuery} The PostgresSQL condition * @mermaid * sequenceDiagram * participant Statement * participant translateOperators * participant parseCondition * * Statement->>Statement: parseCondition(condition) * * Note over Statement: Extract condition parts * * alt Simple comparison operator * Statement->>translateOperators: translateOperators(operator) * translateOperators-->>Statement: Return PostgreSQL operator * Statement->>Statement: Create condition with column, operator, and value * else NOT operator * Statement->>Statement: parseCondition(attr1) * Statement->>Statement: Add NOT to conditions * else AND/OR operator * Statement->>Statement: parseCondition(attr1) * Statement->>Statement: parseCondition(comparison) * Statement->>Statement: Combine conditions with AND/OR * end * * Statement-->>Statement: Return conditions array */ parseCondition(condition, tableName) { let valueCount = 0; const { attr1, operator, comparison } = condition; let postgresCondition; // For simple comparison operators if ([GroupOperator.AND, GroupOperator.OR, Operator.NOT].indexOf(operator) === -1) { const sqlOperator = translateOperators(operator); postgresCondition = { query: ` ${tableName}.${attr1} ${sqlOperator} $${++valueCount}`, values: [comparison], valueCount: valueCount, }; return postgresCondition; } // For NOT operator else if (operator === Operator.NOT) { throw new Error("NOT operator not implemented"); // const nestedConditions = this.parseCondition(attr1 as Condition<M>); // // Apply NOT to each condition // return nestedConditions.map((cond) => ({ // ...cond, // operator: `NOT ${cond.operator}`, // })); } // For AND/OR operators else { const leftConditions = this.parseCondition(attr1, tableName); const rightConditions = this.parseCondition(comparison, tableName); const updatedRightQuery = rightConditions.query.replace(/\$(\d+)/g, (_, num) => `$${Number(num) + leftConditions.values.length}`); postgresCondition = { query: ` ((${leftConditions.query}) ${operator} (${updatedRightQuery}))`, values: [...leftConditions.values, ...rightConditions.values], }; return postgresCondition; } } } /** * @summary Abstract implementation of a Sequence * @description provides the basic functionality for {@link Sequence}s * * @param {SequenceOptions} options * * @class PostgresSequence * @implements Sequence */ class PostgresSequence extends Sequence { constructor(options, adapter) { super(options); this.adapter = adapter; } /** * @summary Retrieves the current value for the sequence * @protected */ async current() { const { name } = this.options; try { const seq = await this.adapter.raw({ query: `SELECT current_value FROM information_schema.sequences WHERE sequence_name = $1`, values: [name], }, true); return this.parse(seq.current_value); } catch (e) { throw this.adapter.parseError(e); } } /** * @summary Parses the {@link Sequence} value * * @protected * @param value */ parse(value) { return Sequence.parseValue(this.options.type, value); } /** * @summary increments the sequence * @description Sequence specific implementation * * @param {string | number | bigint} current * @param count * @protected */ async increment(current, count) { const { type, incrementBy, name, startWith } = this.options; if (type !== "Number" && type !== "BigInt") throw new InternalError(`Cannot increment sequence of type ${type} with ${count}`); let next; try { next = await this.adapter.raw({ query: `SELECT nextval($1);`, values: [name], }, true); } catch (e) { if (!(e instanceof NotFoundError)) throw e; next = await this.adapter.raw({ query: `CREATE SEQUENCE IF NOT EXISTS $1 START WITH $2 INCREMENT BY $3 NO CYCLE;`, values: [name, startWith, incrementBy], }, true); } return next; } /** * @summary Generates the next value in th sequence * @description calls {@link Sequence#parse} on the current value * followed by {@link Sequence#increment} * */ async next() { const current = await this.current(); return this.increment(current); } async range(count) { const current = (await this.current()); const incrementBy = this.parse(this.options.incrementBy); const next = await this.increment(current, this.parse(count) * incrementBy); const range = []; for (let i = 1; i <= count; i++) { range.push(current + incrementBy * this.parse(i)); } if (range[range.length - 1] !== next) throw new InternalError("Miscalculation of range"); return range; } } /** * @description Generates a name for a CouchDB index * @summary Creates a standardized name for a CouchDB index by combining name parts, compositions, and direction * @param {string[]} name - Array of name parts for the index * @param {OrderDirection} [direction] - Optional sort direction for the index * @param {string[]} [compositions] - Optional additional attributes to include in the index name * @param {string} [separator=DefaultSeparator] - The separator to use between parts of the index name * @return {string} The generated index name * @memberOf module:for-couchdb */ function generateIndexName(name, direction, compositions, separator = DefaultSeparator) { return [ ...name.map((n) => (n === PostgresKeys.TABLE ? "table" : n)), ...([]), ...([]), PostgresKeys.INDEX, ].join(separator); } /** * @description Generates CouchDB index configurations for models * @summary Creates a set of CouchDB index configurations based on the metadata of the provided models * @template M - The model type that extends Model * @param models - Array of model constructors to generate indexes for * @return {PostgresQuery} Array of CouchDB index configurations * @function generateIndexes * @memberOf module:for-couchdb * @mermaid * sequenceDiagram * participant Caller * participant generateIndexes * participant generateIndexName * participant Repository * * Caller->>generateIndexes: models * * Note over generateIndexes: Create base table index * generateIndexes->>generateIndexName: [CouchDBKeys.TABLE] * generateIndexName-->>generateIndexes: tableName * generateIndexes->>generateIndexes: Create table index config * * loop For each model * generateIndexes->>Repository: Get indexes metadata * Repository-->>generateIndexes: index metadata * * loop For each index in metadata * Note over generateIndexes: Extract index properties * generateIndexes->>Repository: Get table name * Repository-->>generateIndexes: tableName * * Note over generateIndexes: Define nested generate function * * generateIndexes->>generateIndexes: Call generate() for default order * Note over generateIndexes: Create index name and config * * alt Has directions * loop For each direction * generateIndexes->>generateIndexes: Call generate(direction) * Note over generateIndexes: Create ordered index config * end * end * end * end * * generateIndexes-->>Caller: Array of index configurations */ function generateIndexes(models) { const tableName = generateIndexName([PostgresKeys.TABLE]); const indexes = {}; indexes[tableName] = { query: ``, values: [], }; models.forEach((m) => { const ind = Repository.indexes(m); Object.entries(ind).forEach(([key, value]) => { const k = Object.keys(value)[0]; let { compositions } = value[k]; const tableName = Repository.table(m); compositions = compositions || []; function generate() { const name = [key, ...compositions, PersistenceKeys.INDEX].join(DefaultSeparator); indexes[name] = { query: `CREATE INDEX $1 ON $2 ($3);`, values: [name, tableName, key], }; } generate(); }); }); return Object.values(indexes); } /** * @description Type for PostgreSQL database repositories * @summary A specialized repository type for working with PostgreSQL databases, extending the base Repository * with PostgreSQL-specific adapter, flags, and context types * @template M - Type extending Model that this repository will manage * @memberOf module:for-postgres */ class PostgresRepository extends Repository { constructor(adapter, model, ...args) { super(adapter, model, ...args); } /** * @description Reads a model from the database by ID. * @summary Retrieves a model instance from the database using its primary key. * @param {string|number|bigint} id - The primary key of the model to read. * @param {...any[]} args - Additional arguments. * @return {Promise<M>} The retrieved model instance. */ async read(id, // eslint-disable-next-line @typescript-eslint/no-unused-vars ...args) { const m = await this.adapter.read(this.tableName, id, this.pk); return this.adapter.revert(m, this.class, this.pk, id); } /** * @description Deletes a model from the database by ID. * @summary Removes a model instance from the database using its primary key. * @param {string|number|bigint} id - The primary key of the model to delete. * @param {...any[]} args - Additional arguments. * @return {Promise<M>} The deleted model instance. */ async delete(id, ...args) { const m = await this.adapter.delete(this.tableName, id, this.pk, ...args); return this.adapter.revert(m, this.class, this.pk, id); } async createAllPrefix(models, ...args) { const contextArgs = await Context.args(OperationKeys.CREATE, this.class, args, this.adapter, this._overrides || {}); if (!models.length) return [models, ...contextArgs.args]; models = await Promise.all(models.map(async (m) => { m = new this.class(m); await enforceDBDecorators(this, contextArgs.context, m, OperationKeys.CREATE, OperationKeys.ON); return m; })); const errors = models .map((m) => m.hasErrors(...(contextArgs.context.get("ignoredValidationProperties") || []))) .reduce((accum, e, i) => { if (e) accum = typeof accum === "string" ? accum + `\n - ${i}: ${e.toString()}` : ` - ${i}: ${e.toString()}`; return accum; }, undefined); if (errors) throw new ValidationError(errors); return [models, ...contextArgs.args]; } async createAll(models, ...args) { if (!models.length) return models; const prepared = models.map((m) => this.adapter.prepare(m, this.pk)); const ids = prepared.map((p) => p.id); let records = prepared.map((p) => p.record); records = await this.adapter.createAll(this.tableName, ids, records, ...args); return records.map((r, i) => this.adapter.revert(r, this.class, this.pk, ids[i])); } async readAll(keys, ...args) { const records = await this.adapter.readAll(this.tableName, keys, this.pk, ...args); return records.map((r, i) => this.adapter.revert(r, this.class, this.pk, keys[i])); } async updateAll(models, ...args) { const records = models.map((m) => this.adapter.prepare(m, this.pk)); const updated = await this.adapter.updateAll(this.tableName, records.map((r) => r.id), records.map((r) => r.record), this.pk, ...args); return updated.map((u, i) => this.adapter.revert(u, this.class, this.pk, records[i].id)); } async deleteAll(keys, ...args) { const results = await this.adapter.deleteAll(this.tableName, keys, this.pk, ...args); return results.map((r, i) => this.adapter.revert(r, this.class, this.pk, keys[i])); } } /** * @description Dispatcher for PostgreSQL database change events * @summary Handles the subscription to and processing of database change events from a PostgreSQL database, * notifying observers when records are created, updated, or deleted * @template Pool - The pg Pool type * @param {number} [timeout=5000] - Timeout in milliseconds for notification requests * @class PostgresDispatch * @example * ```typescript * // Create a dispatcher for a PostgreSQL database * const pool = new Pool({ * user: 'postgres', * password: 'password', * host: 'localhost', * port: 5432, * database: 'mydb' * }); * const adapter = new PostgreSQLAdapterImpl(pool); * const dispatch = new PostgreSQLDispatch(); * * // The dispatcher will automatically subscribe to notifications * // and notify observers when records change * ``` * @mermaid * classDiagram * class Dispatch { * +initialize() * +updateObservers() * } * class PostgreSQLDispatch { * -observerLastUpdate?: string * -attemptCounter: number * -timeout: number * -client?: PoolClient * +constructor(timeout) * #notificationHandler() * #initialize() * } * Dispatch <|-- PostgreSQLDispatch */ class PostgresDispatch extends Dispatch { constructor(timeout = 5000) { super(); this.timeout = timeout; this.attemptCounter = 0; } /** * @description Processes database notification events * @summary Handles the notifications from PostgreSQL LISTEN/NOTIFY mechanism, * and notifies observers about record changes * @param {Notification} notification - The notification from PostgreSQL * @return {Promise<void>} A promise that resolves when all notifications have been processed * @mermaid * sequenceDiagram * participant D as PostgreSQLDispatch * participant L as Logger * participant O as Observers * Note over D: Receive notification from PostgreSQL * D->>D: Parse notification payload * D->>D: Extract table, operation, and ids * D->>O: updateObservers(table, operation, ids) * D->>D: Update observerLastUpdate * D->>L: Log successful dispatch */ async notificationHandler(notification) { const log = this.log.for(this.notificationHandler); try { // Parse the notification payload (expected format: table:operation:id1,id2,...) const payload = notification.payload; const [table, operation, idsString] = payload.split(":"); const ids = idsString.split(","); if (!table || !operation || !ids.length) { return log.error(`Invalid notification format: ${payload}`); } // Map operation string to OperationKeys let operationKey; switch (operation.toLowerCase()) { case "insert": operationKey = OperationKeys.CREATE; break; case "update": operationKey = OperationKeys.UPDATE; break; case "delete": operationKey = OperationKeys.DELETE; break; default: return log.error(`Unknown operation: ${operation}`); } // Notify observers await this.updateObservers(table, operationKey, ids); this.observerLastUpdate = new Date().toISOString(); log.verbose(`Observer refresh dispatched by ${operation} for ${table}`); log.debug(`pks: ${ids}`); } catch (e) { log.error(`Failed to process notification: ${e}`); } } /** * @description Initializes the dispatcher and subscribes to database notifications * @summary Sets up the LISTEN mechanism to subscribe to PostgreSQL notifications * and handles reconnection attempts if the connection fails * @return {Promise<void>} A promise that resolves when the subscription is established * @mermaid * sequenceDiagram * participant D as PostgreSQLDispatch * participant S as subscribeToPostgreSQL * participant DB as PostgreSQL Database * participant L as Logger * D->>S: Call subscribeToPostgreSQL * S->>S: Check adapter and native * alt No adapter or native * S-->>S: throw InternalError * end * S->>DB: Connect client from pool * S->>DB: LISTEN table_changes * alt Success * DB-->>S: Subscription established * S-->>D: Promise resolves * D->>L: Log successful subscription * else Error * DB-->>S: Error * S->>S: Increment attemptCounter * alt attemptCounter > 3 * S->>L: Log error * S-->>D: Promise rejects * else attemptCounter <= 3 * S->>L: Log retry * S->>S: Wait timeout * S->>S: Recursive call to subscribeToPostgreSQL * end * end */ async initialize() { const log = this.log.for(this.initialize); async function subscribeToPostgres() { if (!this.adapter || !this.native) { throw new InternalError(`No adapter/native observed for dispatch`); } try { this.client = await this.native.connect(); this.client.on("notification", this.notificationHandler.bind(this)); // Listen for table change notifications // This assumes you have set up triggers in PostgreSQL to NOTIFY on table changes const res = await this.client.query("LISTEN user_table_changes"); this.attemptCounter = 0; } catch (e) { if (this.client) { this.client.release(); this.client = undefined; } if (++this.attemptCounter > 3) { return log.error(`Failed to subscribe to Postgres notifications: ${e}`); } log.info(`Failed to subscribe to Postgres notifications: ${e}. Retrying in ${this.timeout}ms...`); await new Promise((resolve) => setTimeout(resolve, this.timeout)); return subscribeToPostgres.call(this); } } subscribeToPostgres .call(this) .then(() => { this.log.info(`Subscribed to Postgres notifications`); }) .catch((e) => { throw new InternalError(`Failed to subscribe to Postgres notifications: ${e}`); }); } /** * Cleanup method to release resources when the dispatcher is no longer needed */ cleanup() { if (this.client) { this.client.release(); this.client = undefined; } } } /** * Converts a JavaScript RegExp pattern to a PostgreSQL POSIX pattern * @param jsRegex JavaScript RegExp object or pattern string * @returns PostgreSQL compatible regex pattern string */ function convertJsRegexToPostgres(jsRegex) { const rxp = new RegExp(/^\/(.+)\/(\w+)$/g); if (typeof jsRegex === "string") { const match = rxp.exec(jsRegex); if (match) { const [, p, flags] = match; jsRegex = p; } } const regex = typeof jsRegex === "string" ? new RegExp(jsRegex) : jsRegex; const pattern = regex.source; return pattern; } async function createdByOnPostgresCreateUpdate(context, data, key, model) { try { const user = context.get("user"); model[key] = user; // eslint-disable-next-line @typescript-eslint/no-unused-vars } catch (e) { throw new InternalError("No User found in context. Please provide a user in the context"); } } /** * @description Abstract adapter for Postgres database operations * @summary Provides a base implementation for Postgres database operations, including CRUD operations, sequence management, and error handling * @template Y - The scope type * @template F - The repository flags type * @template C - The context type * @param {Y} scope - The scope for the adapter * @param {string} flavour - The flavour of the adapter * @param {string} [alias] - Optional alias for the adapter * @class PostgresAdapter */ class PostgresAdapter extends Adapter { constructor(pool, alias) { super(pool, PostgresFlavour, alias); } async flags(operation, model, flags) { const f = await super.flags(operation, model, flags); const newObj = { user: (await PostgresAdapter.getCurrentUser(this.native)), }; if (operation === "create" || operation === "update") { const pk = findPrimaryKey(new model()).id; newObj.ignoredValidationProperties = (f.ignoredValidationProperties ? f.ignoredValidationProperties : []).concat(pk); } return Object.assign(f, newObj); } Dispatch() { return new PostgresDispatch(); } repository() { return PostgresRepository; } /** * @description Creates a new Postgres statement for querying * @summary Factory method that creates a new PostgresStatement instance for building queries * @template M - The model type * @return {PostgresStatement<M, any>} A new PostgresStatement instance */ Statement() { return new PostgresStatement(this); } /** * @description Creates a new PostgreSQL sequence * @summary Factory method that creates a new PostgreSQLSequence instance for managing sequences * @param {SequenceOptions} options - The options for the sequence * @return {Promise<Sequence>} A promise that resolves to a new Sequence instance */ async Sequence(options) { return new PostgresSequence(options, this); } /** * @description Initializes the adapter by creating indexes for all managed models * @summary Sets up the necessary database indexes for all models managed by this adapter * @return {Promise<void>} A promise that resolves when initialization is complete */ async initialize() { const managedModels = Adapter.models(this.flavour); return this.index(...managedModels); } /** * @description Creates indexes for the given models * @summary Abstract method that must be implemented to create database indexes for the specified models * @template M - The model type * @param {...Constructor<M>} models - The model constructors to create indexes for * @return {Promise<void>} A promise that resolves when all indexes are created */ async index(...models) { const indexes = generateIndexes(models); const client = await this.native.connect(); try { await client.query("BEGIN"); for (const index of indexes) { await client.query(index.query, index.values); } await client.query("COMMIT"); } catch (e) { await client.query("ROLLBACK"); throw this.parseError(e); } finally { client.release(); } } /** * @description Executes a raw SQL query against the database * @summary Abstract method that must be implemented to execute raw SQL queries * @template R - The result type * @param {PostgresQuery} q - The query to execute * @param {boolean} rowsOnly - Whether to return only the rows or the full response * @return {Promise<R>} A promise that resolves to the query result */ async raw(q, rowsOnly) { const client = await this.native.connect(); try { const { query, values } = q; const response = await client.query(query, values); if (rowsOnly) return response.rows; return response; } catch (e) { throw this.parseError(e); } finally { client.release(); } } prepare(model, pk) { const prepared = super.prepare(model, pk); prepared.record = Object.entries(prepared.record).reduce((accum, [key, value]) => { if (key === PersistenceKeys.METADATA || this.isReserved(key) || key === pk) return accum; if (value === undefined) { return accum; } if (value instanceof Date) { value = new Date(value.getTime()); } else { switch (typeof value) { case "string": value = `${value}`; break; //do nothing; } } accum[key] = value; return accum; }, {}); return prepared; } revert(obj, clazz, pk, id, transient) { const log = this.log.for(this.revert); const ob = {}; ob[pk] = id || obj[pk]; const m = (typeof clazz === "string" ? Model.build(ob, clazz) : new clazz(ob)); log.silly(`Rebuilding model ${m.constructor.name} id ${id}`); const result = Object.keys(m).reduce((accum, key) => { accum[key] = obj[Repository.column(accum, key)]; return accum; }, m); if (transient) { log.verbose(`re-adding transient properties: ${Object.keys(transient).join(", ")}`); Object.entries(transient).forEach(([key, val]) => { if (key in result) throw new InternalError(`Transient property ${key} already exists on model ${m.constructor.name}. should be impossible`); result[key] = val; }); } return result; } /** * @description Creates a new record in the database * @summary Abstract method that must be implemented to create a new record * @param {string} tableName - The name of the table * @param {string|number} id - The ID of the record * @param {Record<string, any>} model - The model to create * @param {...any[]} args - Additional arguments * @return {Promise<Record<string, any>>} A promise that resolves to the created record */ async create(tableName, id, model, // eslint-disable-next-line @typescript-eslint/no-unused-vars ...args) { const values = Object.values(model); const sql = `INSERT INTO ${tableName} (${Object.keys(model)}) VALUES (${values.map((_, i) => `$${i + 1}`)}) RETURNING *`; const response = await this.raw({ query: sql, values: values }, false); const { rows } = response; return r