@decaf-ts/for-postgres
Version:
template for ts projects
1,242 lines (1,229 loc) • 252 kB
JavaScript
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