diff --git a/README.md b/README.md index 3296855..efa8bab 100644 --- a/README.md +++ b/README.md @@ -68,3 +68,54 @@ await pdo .where('id', '>', 1) .execute(); ``` + +###### Transactions + +```aidl +await pdo.transaction(async (trxPdo) => { + const user = await trxPdo + .select() + .from('users') + .where('uid', '=', 1) + .forUpdate() + .first() + .execute(); + + await trxPdo + .update({ + name: 'Updated' + }) + .table('users') + .where('uid', '=', user.uid) + .execute(); +}); +``` + +###### Row Locks + +```aidl +const row = await pdo + .select() + .from('jobs') + .where('status', '=', 'pending') + .forUpdate() + .skipLocked() + .first() + .execute(); +``` + +Available helpers: + +- `forUpdate()` +- `forShare()` +- `skipLocked()` +- `noWait()` +- `first()` +- `returningOne()` + +###### Fork / withClient + +```aidl +const trxPdo = pdo.withClient(trx); +const isolatedBuilder = pdo.fork(); +``` diff --git a/index.d.ts b/index.d.ts new file mode 100644 index 0000000..31b8cfb --- /dev/null +++ b/index.d.ts @@ -0,0 +1,118 @@ +import postgres = require("postgres"); + +interface IPdoOptions { + escapeCb?: (sqlUnsavedQuery: string) => string; + unescapeCb?: (sqlUnsavedQuery: string) => string; + getLogDuration?: (query: string, durationMs: number) => void; +} + +export class Pdo { + client: postgres.Sql; + escape: (sqlUnsavedQuery: string) => string; + + unescape: (sqlUnsavedQuery: string) => string; + + constructor(client: postgres.Sql, options: IPdoOptions) + + catch(fn: (error: any) => void): Pdo; + + setEscape(escape?: (sqlUnsavedQuery: string) => string): Pdo; + + withClient(client: postgres.Sql | postgres.TransactionSql): Pdo; + + fork(client?: postgres.Sql | postgres.TransactionSql): Pdo; + + transaction( + fn: (trxPdo: Pdo, trx: postgres.TransactionSql) => Promise | TResult + ): Promise; + + clean() : void + + select(select?: string[]): Pdo + + from(table: string): Pdo + + + update(pairs: { [key: string]: any } | null): Pdo + + set(pairs: { [key: string]: any } ): Pdo; + + table(table: string): Pdo; + + insert(columns: { [key: string]: any } | string[] | null): Pdo; + + returning(id: string | string[]): Pdo; + + columns(columns: string[]): Pdo; + + values(values: string[]): Pdo; + + into(table: string): Pdo; + + delete(table: string): Pdo; + + leftJoin(joinTable: string, field1: string, cond: string, field2: string, clause?: string, condWhere?: string, value?: any): Pdo; + + where(clause: string, cond: string, value: string, skipEscape?: boolean): Pdo; + + whereIsNull(clause: string): Pdo; + + whereIsNotNull(clause: string): Pdo; + + orWhereIsNull(clause: string): Pdo; + + orWhereIsNotNull(clause: string): Pdo; + + andWhereIsNull(clause: string): Pdo; + + andWhereIsNotNull(clause: string): Pdo; + + andWhere(clause: string, cond: string, value: string): Pdo; + + orWhere(clause: string, cond: string, value: string): Pdo; + + whereIn(clause: string, arr: string[]): Pdo; + + andWhereIn(clause:string, arr: string[]): Pdo; + + andWhereNotIn(clause: string, arr: string[]): Pdo; + + andWithBrace() : Pdo; + + orWithBrace() : Pdo; + + closeBrace() : Pdo; + + having(clause: string, cond: string, value: string, skipEscape?: boolean): Pdo; + + conflictDoNothing(): Pdo; + + conflictDoUpdate(clause: string, pairs: { [key: string]: any }): Pdo; + + groupBy(groupBy: string): Pdo; + + orderBy(column: string, direction: 'DESC' | 'ASC'): Pdo; + + limit(limit: number, offset?: number): Pdo; + + with(subQueries: string | string[]): Pdo; + + forUpdate(): Pdo; + + forShare(): Pdo; + + skipLocked(): Pdo; + + noWait(): Pdo; + + first(): Pdo; + + returningOne(id: string | string[]): Pdo; + + escapeData(value: any): string; + join(arr: string[]): string; + + getQuery(): string | undefined; + + execute(): Promise; +} diff --git a/index.js b/index.js index c4414d7..4cad0e9 100644 --- a/index.js +++ b/index.js @@ -1,9 +1,46 @@ class Pdo { constructor(client, options = {}) { this.client = client; + this._options = options; this.escape = options.escapeCb ?? encodeURI; this.unescape = options.unescapeCb ?? unescape; this.clean(); + this._catch = console.error; + this._getLogDuration = options.getLogDuration; + } + + catch(fn) { + this._catch = fn; + return this; + } + + setEscape(escape) { + this.escape = escape ?? encodeURI; + return this; + } + + withClient(client) { + const pdo = new Pdo(client, this._options); + pdo._catch = this._catch; + pdo._getLogDuration = this._getLogDuration; + pdo.escape = this.escape; + pdo.unescape = this.unescape; + return pdo; + } + + fork(client = this.client) { + return this.withClient(client); + } + + async transaction(fn) { + try { + return await this.client.begin(async trx => { + const trxPdo = this.withClient(trx); + return await fn(trxPdo, trx); + }); + } catch (e) { + this._catch(e); + } } clean() { @@ -14,19 +51,26 @@ class Pdo { this._values = null; this._groupBy = null; this._select = null; - this._join = null; + this._join = []; this._orderBy = null; this._orderDirection = null; this._limit = null; this._offset = null; this._returning = null; + this._conflict = null; + this._having = null; + this._subQueries = []; + this._lock = null; + this._skipLocked = false; + this._noWait = false; + this._first = false; } select(select = ['*']) { - this.clean(); - this._type = 'select' - this._select = select; - return this; + const query = this.fork(); + query._type = 'select' + query._select = select; + return query; } from(table) { @@ -35,12 +79,12 @@ class Pdo { } update(pairs = null) { - this.clean(); - this._type = 'update'; + const query = this.fork(); + query._type = 'update'; if (pairs) { - this.set(pairs); + query.set(pairs); } - return this; + return query; } set(pairs) { @@ -59,19 +103,19 @@ class Pdo { } insert(columns = null) { - this.clean(); - this._type = 'insert'; + const query = this.fork(); + query._type = 'insert'; if (columns) { if (Array.isArray(columns)) { - this.columns(columns); + query.columns(columns); } else { - this.columns(Object.keys(columns)); - this.values(Object.values(columns)); + query.columns(Object.keys(columns)); + query.values(Object.values(columns)); } } else { - this.columns(columns); + query.columns(columns); } - return this; + return query; } returning(id) { @@ -95,19 +139,19 @@ class Pdo { } delete(table) { - this.clean(); - this._type = 'delete'; - this._table = table; - return this; + const query = this.fork(); + query._type = 'delete'; + query._table = table; + return query; } - leftJoin(joinTable, field1, cond, field2) { - this._join = `LEFT JOIN ${joinTable} ON ${field1} ${cond} ${field2}`; + leftJoin(joinTable, field1, cond, field2, clause, condWhere, value) { + this._join.push(`LEFT JOIN ${joinTable} ON ${field1} ${cond} ${field2}${clause ? ` AND ${clause} ${condWhere} ${this.escapeData(value)}` : ''}`); return this; } - where(clause, cond, value) { - this._where = `${clause} ${cond} ${this.escapeData(value)}`; + where(clause, cond, value, skipEscape = false) { + this._where = `${clause} ${cond} ${skipEscape ? value : this.escapeData(value)}`; return this; } @@ -116,16 +160,31 @@ class Pdo { return this; } + whereIsNotNull(clause) { + this._where = `${clause} IS NOT NULL`; + return this; + } + orWhereIsNull(clause) { this._where = `${this._where} OR ${clause} IS NULL`; return this; } + orWhereIsNotNull(clause) { + this._where = `${this._where} OR ${clause} IS NOT NULL`; + return this; + } + andWhereIsNull(clause) { this._where = `${this._where} AND ${clause} IS NULL`; return this; } + andWhereIsNotNull(clause) { + this._where = `${this._where} AND ${clause} IS NOT NULL`; + return this; + } + andWhere(clause, cond, value) { this._where = `${this._where} AND ${clause} ${cond} ${this.escapeData(value)}`; return this; @@ -142,7 +201,38 @@ class Pdo { } andWhereIn(clause, arr) { - this._where = `${this._where} AND ${clause} IN (${arr.map(this.escapeData).join(',')})`; + this._where = `${this._where} AND ${clause} IN (${arr.map(item => this.escapeData(item)).join(',')})`; + return this; + } + + andWhereNotIn(clause, arr) { + this._where = `${this._where} AND ${clause} NOT IN (${arr.map(item => this.escapeData(item)).join(',')})`; + return this; + } + + andWithBrace() { + this._where = `${this._where} AND (1 = 1`; + return this; + } + + orWithBrace() { + this._where = `${this._where} OR (1 = 1`; + return this; + } + + closeBrace() { + this._where = `${this._where})`; + return this; + } + + conflictDoNothing() { + this._conflict = `DO NOTHING`; + return this; + } + + conflictDoUpdate(clause, pairs) { + this._conflict = `(${clause}) DO UPDATE SET ${Object.keys(pairs).map(column => + (`${column} = ${this.escapeData(pairs[column])}`)).join(',')}`; return this; } @@ -157,15 +247,73 @@ class Pdo { return this; } + having(clause, cond, value, skipEscape = false) { + this._having = `${clause} ${cond} ${skipEscape ? value : this.escapeData(value)}`; + return this; + } + + andHaving(clause, cond, value) { + this._having = `${this._having} AND ${clause} ${cond} ${this.escapeData(value)}`; + return this; + } + + orHaving(clause, cond, value) { + this._having = `${this._having} OR ${clause} ${cond} ${this.escapeData(value)}`; + return this; + } + limit(limit, offset = 0) { this._limit = limit; this._offset = offset; return this; } + with(subQueries) { + if (Array.isArray(subQueries)) + this._subQueries = [...subQueries]; + + if (typeof subQueries === 'string') + this._subQueries = [subQueries]; + + return this; + } + + forUpdate() { + this._lock = 'FOR UPDATE'; + return this; + } + + forShare() { + this._lock = 'FOR SHARE'; + return this; + } + + skipLocked() { + this._skipLocked = true; + return this; + } + + noWait() { + this._noWait = true; + return this; + } + + first() { + this._first = true; + if (!this._limit) { + this._limit = 1; + this._offset = 0; + } + return this; + } + + returningOne(id) { + return this.returning(id).first(); + } + escapeData(value) { try { - if (value === null) { + if (value === null || !this.escape) { return value; } else if (typeof value === "boolean") { return value; @@ -191,8 +339,10 @@ class Pdo { case 'select': query = `SELECT ${this.join(this._select)} FROM ${this._table}`; - if (this._join) { - query += ` ${this._join}`; + if (this._join.length) { + this._join.map(str => { + query += ` ${str}`; + }); } if (this._where) { query += ` WHERE ${this._where}`; @@ -200,6 +350,9 @@ class Pdo { if (this._groupBy) { query += ` GROUP BY ${this._groupBy}`; } + if (this._having) { + query += ` HAVING ${this._having}`; + } if (this._orderBy) { query += ` ORDER BY ${this._orderBy} ${this._orderDirection}`; } @@ -209,26 +362,50 @@ class Pdo { if (this._offset) { query += ` OFFSET ${this._offset}`; } + if (this._lock) { + query += ` ${this._lock}`; + } + if (this._skipLocked) { + query += ` SKIP LOCKED`; + } + if (this._noWait) { + query += ` NOWAIT`; + } + if (this._subQueries.length) { + query = `WITH ${this._subQueries.join(',')} ${query}`; + } break; case 'insert': query = `INSERT INTO ${this._table} (${this._columns?.join(',')}) VALUES (${this.join(this._values?.map((v) => this.escapeData(v)))})`; + if (this._conflict) { + query += ` ON CONFLICT ${this._conflict}`; + } if (this._returning) { query += ` RETURNING ${this._returning}`; } + if (this._subQueries.length) { + query = `WITH ${this._subQueries.join(',')} ${query}`; + } break; case 'update': query = `UPDATE ${this._table} SET ${this._values?.map((value, i) => { return `${this._columns[i]} = ${this.escapeData(value)}`; }).join(',')}`; + if (this._conflict) { + query += ` ON CONFLICT ${this._conflict}`; + } if (this._where) { query += ` WHERE ${this._where}`; } if (this._returning) { query += ` RETURNING ${this._returning}`; } + if (this._subQueries.length) { + query = `WITH ${this._subQueries.join(',')} ${query}`; + } break; case 'delete': query = `DELETE @@ -236,20 +413,31 @@ class Pdo { if (this._where) { query += ` WHERE ${this._where}`; } + if (this._returning) { + query += ` RETURNING ${this._returning}`; + } + if (this._subQueries.length) { + query = `WITH ${this._subQueries.join(',')} ${query}`; + } break; } return query; } - execute() { + async execute() { + const startTime = new Date().getTime(); const query = this.getQuery(); try { - return this.client.unsafe(query); - } catch (e) { - throw { - error: e, - query, + const result = await this.client.unsafe(query); + if (this._getLogDuration) { + this._getLogDuration(query.replace(/\n/g, ' ').replace(/\s+/g, ' '), new Date().getTime() - startTime); + } + if (this._first) { + return result?.[0]; } + return result; + } catch (e) { + this._catch(e); } } } diff --git a/package.json b/package.json index 9ba9c0f..95669a6 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,12 @@ { "name": "postgres-pdo", - "version": "0.0.13", + "version": "0.1.3", "description": "Postgres Data Objects", "main": "index.js", + "types": "index.d.ts", + "keywords": ["postgres", "pdo", "database", "typescript"], "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "test": "node ./test.js" }, "repository": { "type": "git", @@ -15,5 +17,8 @@ "bugs": { "url": "https://github.com/wowDaiver/postgres-pdo/issues" }, - "homepage": "https://github.com/wowDaiver/postgres-pdo#readme" + "homepage": "https://github.com/wowDaiver/postgres-pdo#readme", + "dependencies": { + "postgres": "^3.4.5" + } } diff --git a/test.js b/test.js new file mode 100644 index 0000000..b6f4728 --- /dev/null +++ b/test.js @@ -0,0 +1,98 @@ +const assert = require('assert'); +const Pdo = require('./index'); + +function createMockClient() { + return { + queries: [], + async unsafe(query) { + this.queries.push(query); + return [{ id: 1, uid: 42 }]; + }, + async begin(fn) { + const trx = { + queries: [], + async unsafe(query) { + this.queries.push(query); + return [{ id: 2, uid: 99 }]; + }, + }; + return await fn(trx); + }, + }; +} + +(async () => { + const client = createMockClient(); + const pdo = new Pdo(client); + + const lockedRow = await pdo + .fork() + .select() + .from('users') + .where('uid', '=', 42) + .forUpdate() + .skipLocked() + .first() + .execute(); + + assert.deepStrictEqual(lockedRow, { id: 1, uid: 42 }); + assert.strictEqual( + client.queries[0], + "SELECT *\n FROM users WHERE uid = 42 LIMIT 1 FOR UPDATE SKIP LOCKED" + ); + + const returnedRow = await pdo + .fork() + .update({ name: 'Alice' }) + .table('users') + .where('uid', '=', 42) + .returningOne(['id', 'uid']) + .execute(); + + assert.deepStrictEqual(returnedRow, { id: 1, uid: 42 }); + assert.strictEqual( + client.queries[1], + "UPDATE users\n SET name = 'Alice' WHERE uid = 42 RETURNING id,uid" + ); + + const transactionResult = await pdo.transaction(async (trxPdo) => { + const row = await trxPdo + .select() + .from('users') + .where('uid', '=', 99) + .forUpdate() + .noWait() + .first() + .execute(); + + assert.deepStrictEqual(row, { id: 2, uid: 99 }); + return row.uid; + }); + + assert.strictEqual(transactionResult, 99); + + // Verify that parallel queries from the same pdo instance don't interfere + const client2 = createMockClient(); + const sharedPdo = new Pdo(client2); + + const stmt1 = sharedPdo.select().from('users').where('uid', '=', 1).first(); + const stmt2 = sharedPdo.select().from('payments').where('uuid', '=', 'x'); + + assert.strictEqual( + stmt1.getQuery(), + "SELECT *\n FROM users WHERE uid = 1 LIMIT 1" + ); + assert.strictEqual( + stmt2.getQuery(), + "SELECT *\n FROM payments WHERE uuid = 'x'" + ); + + const [r1, r2] = await Promise.all([stmt1.execute(), stmt2.execute()]); + assert.deepStrictEqual(r1, { id: 1, uid: 42 }); + assert.deepStrictEqual(r2, [{ id: 1, uid: 42 }]); + + console.log('postgres-pdo tests passed'); +})().catch((error) => { + console.error(error); + process.exit(1); +});