nodejs-mw-select-builder/select-builder-pgsql.js

758 lines
21 KiB
JavaScript
Raw Normal View History

2019-05-08 00:27:36 +03:00
// Простенький "селект билдер" по мотивам MediaWiki-овского, успешно юзаю подобный в PHP уже лет 8
// (c) Виталий Филиппов, 2019
// Версия 2019-07-04
2019-05-08 00:27:36 +03:00
// В PHP, правда, прикольнее - там в массиве можно смешивать строковые и численные ключи,
// благодаря чему можно писать $where = [ 't1.a=t2.a', 't2.b' => [ 1, 2, 3 ] ]
2019-05-10 01:27:32 +03:00
// Здесь так нельзя, поэтому этот синтаксис мы заменяем на { 't1.a=t2.a': [], 't2.b': [ 1, 2, 3 ] }
2019-05-08 00:27:36 +03:00
const pg = require('pg');
// Сраный node-postgres конвертирует даты в Date и портит таймзону
const DATATYPE_DATE = 1082;
pg.types.setTypeParser(DATATYPE_DATE, function(val)
{
return val === null ? null : val;
});
let pg_escape;
const MS_HASH = 0;
const MS_LIST = 1;
const MS_ROW = 2;
const MS_COL = 4;
const MS_VALUE = 6;
function select_builder(tables, fields, where, options)
2019-05-08 00:27:36 +03:00
{
options = options||{};
2019-05-08 00:27:36 +03:00
let sql = 'SELECT ', bind = [];
if (options['DISTINCT ON'] || options.distinct_on)
{
let group = options['DISTINCT ON'] || options.distinct_on;
group = group instanceof Array ? group : [ group ];
sql += 'DISTINCT ON ('+group.join(', ')+') ';
}
2019-05-08 00:27:36 +03:00
if (fields instanceof Array)
{
sql += fields.join(', ');
}
else if (typeof fields == 'string')
{
sql += fields;
}
else if (typeof fields == 'object')
{
sql += Object.keys(fields).map(k => fields[k]+' AS '+k).join(', ');
}
else
{
throw new Error('fields = '+fields+' is invalid');
}
sql += ' FROM ';
2019-05-10 01:27:32 +03:00
const t = tables_builder(tables);
sql += t.sql;
bind.push.apply(bind, t.bind);
where = where_builder(where);
sql += ' WHERE '+(where.sql || '1=1');
bind.push.apply(bind, where.bind);
if (t.moreWhere)
2019-05-08 00:27:36 +03:00
{
2019-05-10 01:27:32 +03:00
sql += ' AND '+t.moreWhere.sql;
bind.push.apply(bind, t.moreWhere.bind);
2019-05-08 00:27:36 +03:00
}
if (options['GROUP BY'] || options.group_by)
{
let group = options['GROUP BY'] || options.group_by;
group = group instanceof Array ? group : [ group ];
sql += ' GROUP BY '+group.join(', ');
}
if (options['ORDER BY'] || options.order_by)
{
let order = options['ORDER BY'] || options.order_by;
order = order instanceof Array ? order : [ order ];
sql += ' ORDER BY '+order.join(', ');
}
if (options.LIMIT || options.limit)
{
sql += ' LIMIT '+((options.LIMIT || options.limit) | 0);
}
if (options.OFFSET || options.offset)
{
2019-05-15 16:01:40 +03:00
sql += ' OFFSET '+((options.OFFSET || options.offset) | 0);
2019-05-08 00:27:36 +03:00
}
2019-05-15 16:01:40 +03:00
return new Text(sql, bind);
2019-05-10 01:27:32 +03:00
}
function tables_builder(tables)
{
let sql = '', bind = [];
let moreWhere = null;
let first = true;
if (typeof tables == 'string')
{
sql = tables;
return { sql, bind, moreWhere };
}
for (const k in tables)
{
let jointype = 'INNER', table = tables[k], conds = null;
if (table instanceof Array)
{
[ jointype, table, conds ] = table;
}
if (!first)
{
sql += ' ' + jointype.toUpperCase() + ' JOIN ';
}
let more_on;
if (table instanceof Pg_Values)
{
sql += '(VALUES ';
let i = 0;
for (const row of table.rows)
{
sql += (i > 0 ? ', (' : '(') + table.keys.map(() => '?').join(', ')+')';
bind.push.apply(bind, table.keys.map(k => row[k]));
i++;
}
sql += ') AS '+k+'('+table.keys.join(', ')+')';
}
else if (typeof table == 'object')
{
// Nested join, `k` alias is ignored
let subjoin = tables_builder(table);
if (subjoin.moreWhere)
{
more_on = subjoin.moreWhere;
}
if (Object.keys(table).length > 1)
{
sql += "("+subjoin.sql+")";
}
else
{
sql += subjoin.sql;
}
bind.push.apply(subjoin.bind);
}
else
{
sql += table + ' ' + k;
}
conds = where_builder(conds);
if (more_on)
{
if (!conds.sql)
conds = more_on;
else
{
conds.sql += ' AND ' + more_on.sql;
conds.bind.push.apply(conds.bind, more_on.bind);
}
}
if (!first)
{
sql += ' ON ' + (conds.sql || '1=1');
bind.push.apply(bind, conds.bind);
}
else
{
// Бывает удобно указывать WHERE как условие "JOIN" первой таблицы
moreWhere = conds.sql ? conds : null;
first = false;
}
}
return { sql, bind, moreWhere };
2019-05-08 00:27:36 +03:00
}
2019-05-10 01:27:32 +03:00
// fields: one of:
// - string: 'a=b AND c=d'
// - array: [ 'a=b', [ 'a=? or b=?', 1, 2 ], [ 'a', [ 1, 2 ] ] ]
// - object: { a: 1, b: [ 1, 2 ], 'a = b': [], '(a, b)': [ [ 1, 2 ], [ 2, 3 ] ], 'c=? or d=?': [ 2, 3 ] }
// - key does not contain '?', value is a scalar or non-empty array => (key IN ...)
// - key does not contain '?', value is an empty array => just (key)
// - key contains '?', value is a scalar or non-empty array => (key) with bind params (...value)
// - key is numeric, then value is treated as in array
function where_or_set(fields, for_condition)
2019-05-08 00:27:36 +03:00
{
if (typeof fields == 'string')
2019-05-10 01:27:32 +03:00
{
return { sql: fields, bind: [] };
}
2019-05-08 00:27:36 +03:00
const w = [], bind = [];
2019-05-10 01:27:32 +03:00
for (let k in fields)
2019-05-08 00:27:36 +03:00
{
let v = fields[k];
2019-05-10 01:27:32 +03:00
if (/^\d+$/.exec(k))
{
if (v instanceof Array)
{
k = v[0];
v = v.slice(1);
}
else
{
w.push(v);
continue;
}
}
if (k.indexOf('?') >= 0 || v instanceof Array && v.length == 0)
2019-05-08 00:27:36 +03:00
{
if (!(v instanceof Array))
2019-05-10 01:27:32 +03:00
{
2019-05-08 00:27:36 +03:00
v = [ v ];
2019-05-10 01:27:32 +03:00
}
// FIXME: check bind variable count
2019-05-08 00:27:36 +03:00
w.push(k);
bind.push.apply(bind, v);
2019-05-10 01:27:32 +03:00
continue;
2019-05-08 00:27:36 +03:00
}
2019-05-10 01:27:32 +03:00
v = v instanceof Array ? v : [ v ];
if (v.length == 1 && v[0] == null)
2019-05-08 00:27:36 +03:00
{
w.push(for_condition ? k+' is null' : k+' = null');
2019-05-10 01:27:32 +03:00
}
else
{
// a IN (...) or (a, b) IN ((...), ...)
if ((v.length > 1 || v[0] instanceof Array) && !for_condition)
2019-05-08 00:27:36 +03:00
{
2019-05-10 01:27:32 +03:00
throw new Error('IN syntax can only be used inside WHERE');
}
if (v[0] instanceof Array)
{
// (a, b) in ((...), ...)
2019-05-10 01:27:32 +03:00
w.push(k + ' in (' + v.map(vi => '('+vi.map(() => '?').join(', ')+')') + ')');
v.map(vi => bind.push.apply(bind, vi));
2019-05-08 00:27:36 +03:00
}
else if (!for_condition)
{
w.push(k+' = ?');
bind.push(v[0]);
}
2019-05-08 00:27:36 +03:00
else
{
let n = v.length;
v = v.filter(vi => vi != null);
if (v.length > 0)
{
w.push(k+' in (' + v.map(() => '?').join(', ') + ')' + (n > v.length ? ' or '+k+' is null' : ''));
bind.push.apply(bind, v);
}
else if (n > 0)
{
w.push(k+' is null');
}
2019-05-08 00:27:36 +03:00
}
}
}
if (!for_condition)
2019-05-10 01:27:32 +03:00
{
// SET
return { sql: w.join(', '), bind };
}
// WHERE
return { sql: w.length ? '('+w.join(') and (')+')' : '', bind };
2019-05-08 00:27:36 +03:00
}
2019-05-10 01:27:32 +03:00
function where_builder(where)
2019-05-08 00:27:36 +03:00
{
2019-05-10 01:27:32 +03:00
return where_or_set(where, true);
}
/**
* Разбивает набор таблиц на основную обновляемую + набор дополнительных
*
* Идея в том, чтобы обрабатывать хотя бы 2 простые ситуации:
* UPDATE table1 INNER JOIN table2 ...
* UPDATE table1 LEFT JOIN table2 ...
*/
function split_using(tables)
{
if (typeof tables == 'string')
{
return { what: { sql: tables, bind: [] }, using: null, moreWhere: null };
}
let first = null;
let is_next_inner = true;
let i = 0;
for (let k in tables)
{
let t = tables[k];
if (i == 0)
{
if (t instanceof Array && typeof(t[1]) != 'string')
{
throw new Error('Can only update/delete from real tables, not sub-select, sub-join or VALUES');
}
first = k;
}
else if (i == 1)
{
is_next_inner = !(t instanceof Array) || t[0].toLowerCase() == 'inner';
}
else
{
break;
}
i++;
}
let what, moreWhere;
if (is_next_inner)
{
what = tables_builder({ [first]: tables[first] });
delete tables[first];
moreWhere = what.moreWhere;
what.moreWhere = null;
}
else
{
what = tables_builder({ ["_"+first]: tables[first] });
const cond = '_'+first+'.ctid='+(/^\d+$/.exec(first) ? tables[first] : first)+'.ctid';
moreWhere = what.moreWhere
? { sql: what.moreWhere.sql+' AND '+cond, bind: what.moreWhere.bind }
: { sql: cond, bind: [] };
what.moreWhere = null;
}
return { what, using: Object.keys(tables).length > 0 ? tables : null, moreWhere };
2019-05-08 00:27:36 +03:00
}
function quote(v)
{
if (!pg_escape)
pg_escape = require('pg-escape');
if (v == null)
return 'null';
else if (typeof v == 'object')
v = JSON.stringify(v);
else if (typeof v == 'number')
return v;
return '\''+pg_escape.string(v)+'\'';
}
// Превратить bind пераметры, выраженные как ?, в вид $n (как в node-postgres)
2019-05-08 00:27:36 +03:00
function _positional(sql)
{
let i = 0;
2019-05-15 16:01:40 +03:00
sql = sql.replace(/'(?:[^\']*|\'\')*'|"(?:[^\"]*|\"\")*"|(\?)/g, (m, m1) => (m1 ? '$'+(++i) : m));
2019-05-08 00:27:36 +03:00
return sql;
}
// Встроить все bind пераметры, выраженные как ?, в строку
2019-05-08 00:27:36 +03:00
function _inline(sql, bind)
{
if (!pg_escape)
{
pg_escape = require('pg-escape');
}
let i = 0;
sql = sql.replace(
/'(?:[^\']*|\'\')*'|"(?:[^\"]*|\"\")*"|(\?)/g,
(m, m1) =>
{
if (!m1)
return m;
let v = bind[i++];
if (v == null)
return 'null';
else if (typeof v == 'object')
v = JSON.stringify(v);
else if (typeof v == 'number')
return v;
return '\''+pg_escape.string(v)+'\'';
}
);
2019-05-08 00:27:36 +03:00
return sql;
}
// dbh: Connection
2019-05-08 00:27:36 +03:00
async function select(dbh, tables, fields, where, options, format)
{
let sql_text;
if (arguments.length == 2 || arguments.length == 3)
{
sql_text = tables instanceof Text ? tables : new Text(tables, []);
format = fields;
}
else
{
sql_text = select_builder(tables, fields, where, options);
}
let data = await dbh.query(sql_text.sql, sql_text.bind);
2019-05-08 00:27:36 +03:00
if ((format & MS_LIST) || (format & MS_COL))
data = data.rows.map(r => Object.values(r));
else
data = data.rows;
if (format & MS_ROW)
data = data[0];
if (data && (format & MS_COL))
data = data[0];
return data;
}
async function insert(dbh, table, rows, options)
2019-05-08 00:27:36 +03:00
{
if (!(rows instanceof Array))
{
rows = [ rows ];
}
if (!rows.length)
{
return null;
}
if (!pg_escape)
{
pg_escape = require('pg-escape');
}
2019-05-08 00:27:36 +03:00
const keys = Object.keys(rows[0]);
let sql = 'insert into '+table+' ('+keys.join(', ')+') values ';
let i = 0;
for (const row of rows)
{
let j = 0;
sql += (i > 0 ? ', (' : '(');
for (let k of keys)
{
if (j > 0)
sql += ', ';
if (row[k] == null)
sql += 'default';
else if (typeof row[k] == 'object')
sql += '\''+pg_escape(JSON.stringify(row[k]))+'\'';
else
sql += '\''+pg_escape(''+row[k])+'\'';
j++;
}
sql += ')';
i++;
2019-05-08 00:27:36 +03:00
}
if (options)
{
if (options.upsert)
{
sql += ' on conflict '+
(options.upsert instanceof Array
? '('+options.upsert.join(', ')+')'
: (typeof options.upsert == 'string' ? options.upsert : '(id)'))+
' do update set '+
keys.map(k => `${k} = excluded.${k}`).join(', ');
}
else if (options.ignore)
{
sql += ' on conflict '+
(options.ignore instanceof Array
? '('+options.ignore.join(', ')+')'
: (typeof options.ignore == 'string' ? options.ignore : '(id)'))+
' do nothing';
}
if (options.returning)
{
sql += ' returning '+options.returning;
return (await dbh.query(sql)).rows;
}
}
return await dbh.query(sql);
2019-05-08 00:27:36 +03:00
}
2019-05-10 01:27:32 +03:00
async function _delete(dbh, table, where, options)
2019-05-08 00:27:36 +03:00
{
2019-05-10 01:27:32 +03:00
where = where_builder(where);
const split = split_using(table);
if (split.using)
{
split.using = tables_builder(split.using);
}
let sql = 'delete from '+split.what.sql+
(split.using ? ' using '+split.using.sql : '')+
' where '+(where.sql || '1=1')+(split.moreWhere ? ' and '+split.moreWhere.sql : '');
let bind = [ ...split.what.bind, ...where.bind, ...(split.moreWhere ? split.moreWhere.bind : []) ];
if (options && options.returning)
{
sql += ' returning '+options.returning;
return (await dbh.query(sql, bind)).rows;
2019-05-10 01:27:32 +03:00
}
return await dbh.query(sql, bind);
2019-05-08 00:27:36 +03:00
}
2019-05-10 01:27:32 +03:00
async function update(dbh, table, set, where, options)
2019-05-08 00:27:36 +03:00
{
2019-05-10 01:27:32 +03:00
set = where_or_set(set, false);
where = where_builder(where);
const split = split_using(table);
if (split.using)
{
split.using = tables_builder(split.using);
}
let sql = 'update '+split.what.sql+' set '+set.sql+
(split.using ? ' from '+split.using.sql : '')+
' where '+(where.sql || '1=1')+(split.moreWhere ? ' and '+split.moreWhere.sql : '');
let bind = [
...split.what.bind,
...set.bind,
...(split.using ? split.using.bind : []),
...where.bind,
...(split.moreWhere ? split.moreWhere.bind : [])
];
if (options && options.returning)
{
sql += ' returning '+options.returning;
return (await dbh.query(sql, bind)).rows;
2019-05-10 01:27:32 +03:00
}
return await dbh.query(sql, bind);
2019-05-08 00:27:36 +03:00
}
function values(rows)
{
return new Pg_Values(Object.keys(rows[0]), rows);
}
class Pg_Values
{
constructor(keys, rows)
{
this.keys = keys;
this.rows = rows;
}
}
2019-05-15 16:01:40 +03:00
class Text
{
constructor(sql, bind)
{
this.sql = sql;
this.bind = bind || [];
}
toString()
{
return _inline(this.sql, this.bind);
}
concat(text)
{
return new Text(this.sql+text.sql, [ ...this.bind, ...text.bind ]);
}
}
class ConnectionBase
{
async select(tables, fields, where, options, format)
{
return arguments.length <= 2
? await select(this, tables, fields)
: await select(this, tables, fields, where, options, format);
}
async insert(table, rows, options)
{
return await insert(this, table, rows, options);
}
async update(table, set, where, options)
{
return await update(this, table, set, where, options);
}
async delete(table, where, options)
{
return await _delete(this, table, where, options);
}
}
// Обёртка PostgreSQL-подключения
// Автоматически переподключается при отвале
class Connection extends ConnectionBase
{
constructor(config)
{
super();
this.config = config;
this.init_connect = [];
this.in_transaction = null;
this.transaction_queue = [];
this.onerror = (e) =>
{
console.warn(e);
console.warn('Database connection dropped. Reconnecting');
this.dbh = null;
this.connection_lost = true;
this.connect();
};
this.quote = quote;
}
getConnection()
{
return this.dbh;
}
async runWhenConnected(cb)
{
if (this.dbh)
{
await cb(this);
}
this.init_connect.push(cb);
}
dontRunWhenConnected(cb)
{
this.init_connect = this.init_connect.filter(c => c != cb);
}
async connect()
{
if (this.dbh)
{
return this.dbh;
}
let retry = this.config.retry || 30;
// eslint-disable-next-line no-constant-condition
while (true)
{
try
{
this.dbh = new pg.Client(this.config);
await this.dbh.connect();
for (const cb of this.init_connect)
{
await cb(this);
}
this.dbh.on('error', this.onerror);
return this.dbh;
}
catch (e)
{
console.warn(e);
console.warn('Trying to connect again in '+retry+' seconds');
await new Promise((r, j) => setTimeout(r, retry*1000));
}
}
}
async begin()
{
if (this.in_transaction)
{
// Если уже кто-то активен - ждём его
await new Promise((resolve, reject) => this.transaction_queue.push(resolve));
}
this.in_transaction = new Transaction(this);
await this._query('begin');
return this.in_transaction;
}
async query(sql, bind)
{
if (sql.length == 5 && sql.toLowerCase() == 'begin')
{
throw new Error('Do not use transactions in asynchronous code directly!');
}
if (this.in_transaction)
{
// Если уже кто-то активен - ждём его
await new Promise((resolve, reject) => this.transaction_queue.push(resolve));
}
const r = await this._query(sql, bind);
// Если есть ещё кто-то в очереди - пусть проходит
this._next_txn();
return r;
}
_next_txn()
{
this.in_transaction = null;
const next = this.transaction_queue.shift();
if (next)
next();
}
async _query(sql, bind)
{
if (!this.dbh)
await this.connect();
if (this.in_transaction && this.connection_lost)
{
this._next_txn();
throw new Error('Connection lost while in transaction');
}
this.connection_lost = false;
if (this.config.log_queries)
console.log('> '+(bind && bind.length ? _inline(sql, bind) : sql));
try
{
if (!this.in_transaction)
{
this.in_transaction = true;
}
const r = await this.dbh.query(bind && bind.length ? _positional(sql) : sql, bind);
if (this.in_transaction === true)
{
this.in_transaction = false;
}
return r;
}
catch (e)
{
// в postgresql надо откатывать всю транзакцию при любой ошибке
if (this.in_transaction === true)
{
this.in_transaction = false;
}
if (this.in_transaction)
{
await this.in_transaction.query('rollback');
}
else
{
if (this.config.log_queries)
console.log('> rollback');
await this.dbh.query('rollback');
}
throw e;
}
}
}
class Transaction extends ConnectionBase
{
constructor(dbh)
{
super();
this.dbh = dbh;
}
async query(sql, bind)
{
// Здесь уже ждать никого не надо, т.к. если мы сюда попали - то уже дождались своей очереди априори
const r = await this.dbh._query(sql, bind);
if (sql.length == 6 && sql.toLowerCase() == 'commit' ||
sql.length == 8 && sql.toLowerCase() == 'rollback')
{
this.dbh._next_txn();
}
return r;
}
}
2019-05-08 00:27:36 +03:00
module.exports = {
select_builder,
2019-05-10 01:27:32 +03:00
where_builder,
quote,
2019-05-15 16:01:40 +03:00
quote_into: _inline,
quote_positional: _positional,
2019-05-08 00:27:36 +03:00
select,
insert,
delete: _delete,
update,
values,
2019-05-15 16:01:40 +03:00
Text,
Connection,
2019-05-08 00:27:36 +03:00
MS_HASH,
MS_LIST,
MS_ROW,
MS_COL,
MS_VALUE,
};