You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
647 lines
21 KiB
647 lines
21 KiB
/*
|
|
* Copyright (c) 2015-present, Vitaly Tomilov
|
|
*
|
|
* See the LICENSE file at the top-level directory of this distribution
|
|
* for licensing information.
|
|
*
|
|
* Removal or modification of this copyright notice is prohibited.
|
|
*/
|
|
|
|
const {InnerState} = require(`../inner-state`);
|
|
const {assertOptions} = require(`assert-options`);
|
|
const {TableName} = require(`./table-name`);
|
|
const {Column} = require(`./column`);
|
|
|
|
const npm = {
|
|
os: require(`os`),
|
|
utils: require(`../utils`),
|
|
formatting: require(`../formatting`)
|
|
};
|
|
|
|
/**
|
|
* @class helpers.ColumnSet
|
|
* @description
|
|
* Performance-optimized, read-only structure with query-formatting columns.
|
|
*
|
|
* In order to avail from performance optimization provided by this class, it should be created
|
|
* only once, statically, and then reused.
|
|
*
|
|
* @param {object|helpers.Column|array} columns
|
|
* Columns information object, depending on the type:
|
|
*
|
|
* - When it is a simple object, its properties are enumerated to represent both column names and property names
|
|
* within the source objects. See also option `inherit` that's applicable in this case.
|
|
*
|
|
* - When it is a single {@link helpers.Column Column} object, property {@link helpers.ColumnSet#columns columns} is initialized with
|
|
* just a single column. It is not a unique situation when only a single column is required for an update operation.
|
|
*
|
|
* - When it is an array, each element is assumed to represent details for a column. If the element is already of type {@link helpers.Column Column},
|
|
* it is used directly; otherwise the element is passed into {@link helpers.Column Column} constructor for initialization.
|
|
* On any duplicate column name (case-sensitive) it will throw {@link external:Error Error} = `Duplicate column name "name".`
|
|
*
|
|
* - When it is none of the above, it will throw {@link external:TypeError TypeError} = `Invalid parameter 'columns' specified.`
|
|
*
|
|
* @param {object} [options]
|
|
*
|
|
* @param {helpers.TableName|string|{table,schema}} [options.table]
|
|
* Table details.
|
|
*
|
|
* When it is a non-null value, and not a {@link helpers.TableName TableName} object, a new {@link helpers.TableName TableName} is constructed from the value.
|
|
*
|
|
* It can be used as the default for methods {@link helpers.insert insert} and {@link helpers.update update} when their parameter
|
|
* `table` is omitted, and for logging purposes.
|
|
*
|
|
* @param {boolean} [options.inherit = false]
|
|
* Use inherited properties in addition to the object's own properties.
|
|
*
|
|
* By default, only the object's own properties are enumerated for column names.
|
|
*
|
|
* @returns {helpers.ColumnSet}
|
|
*
|
|
* @see
|
|
*
|
|
* {@link helpers.ColumnSet#columns columns},
|
|
* {@link helpers.ColumnSet#names names},
|
|
* {@link helpers.ColumnSet#table table},
|
|
* {@link helpers.ColumnSet#variables variables} |
|
|
* {@link helpers.ColumnSet#assign assign},
|
|
* {@link helpers.ColumnSet#assignColumns assignColumns},
|
|
* {@link helpers.ColumnSet#extend extend},
|
|
* {@link helpers.ColumnSet#merge merge},
|
|
* {@link helpers.ColumnSet#prepare prepare}
|
|
*
|
|
* @example
|
|
*
|
|
* // A complex insert/update object scenario for table 'purchases' in schema 'fiscal'.
|
|
* // For a good performance, you should declare such objects once and then reuse them.
|
|
* //
|
|
* // Column Requirements:
|
|
* //
|
|
* // 1. Property 'id' is only to be used for a WHERE condition in updates
|
|
* // 2. Property 'list' needs to be formatted as a csv
|
|
* // 3. Property 'code' is to be used as raw text, and to be defaulted to 0 when the
|
|
* // property is missing in the source object
|
|
* // 4. Property 'log' is a JSON object with 'log-entry' for the column name
|
|
* // 5. Property 'data' requires SQL type casting '::int[]'
|
|
* // 6. Property 'amount' needs to be set to 100, if it is 0
|
|
* // 7. Property 'total' must be skipped during updates, if 'amount' was 0, plus its
|
|
* // column name is 'total-val'
|
|
*
|
|
* const cs = new pgp.helpers.ColumnSet([
|
|
* '?id', // ColumnConfig equivalent: {name: 'id', cnd: true}
|
|
* 'list:csv', // ColumnConfig equivalent: {name: 'list', mod: ':csv'}
|
|
* {
|
|
* name: 'code',
|
|
* mod: '^', // format as raw text
|
|
* def: 0 // default to 0 when the property doesn't exist
|
|
* },
|
|
* {
|
|
* name: 'log-entry',
|
|
* prop: 'log',
|
|
* mod: ':json' // format as JSON
|
|
* },
|
|
* {
|
|
* name: 'data',
|
|
* cast: 'int[]' // use SQL type casting '::int[]'
|
|
* },
|
|
* {
|
|
* name: 'amount',
|
|
* init(col) {
|
|
* // set to 100, if the value is 0:
|
|
* return col.value === 0 ? 100 : col.value;
|
|
* }
|
|
* },
|
|
* {
|
|
* name: 'total-val',
|
|
* prop: 'total',
|
|
* skip(col) {
|
|
* // skip from updates, if 'amount' is 0:
|
|
* return col.source.amount === 0;
|
|
* }
|
|
* }
|
|
* ], {table: {table: 'purchases', schema: 'fiscal'}});
|
|
*
|
|
* // Alternatively, you could take the table declaration out:
|
|
* // const table = new pgp.helpers.TableName('purchases', 'fiscal');
|
|
*
|
|
* console.log(cs); // console output for the object:
|
|
* //=>
|
|
* // ColumnSet {
|
|
* // table: "fiscal"."purchases"
|
|
* // columns: [
|
|
* // Column {
|
|
* // name: "id"
|
|
* // cnd: true
|
|
* // }
|
|
* // Column {
|
|
* // name: "list"
|
|
* // mod: ":csv"
|
|
* // }
|
|
* // Column {
|
|
* // name: "code"
|
|
* // mod: "^"
|
|
* // def: 0
|
|
* // }
|
|
* // Column {
|
|
* // name: "log-entry"
|
|
* // prop: "log"
|
|
* // mod: ":json"
|
|
* // }
|
|
* // Column {
|
|
* // name: "data"
|
|
* // cast: "int[]"
|
|
* // }
|
|
* // Column {
|
|
* // name: "amount"
|
|
* // init: [Function]
|
|
* // }
|
|
* // Column {
|
|
* // name: "total-val"
|
|
* // prop: "total"
|
|
* // skip: [Function]
|
|
* // }
|
|
* // ]
|
|
* // }
|
|
*/
|
|
class ColumnSet extends InnerState {
|
|
|
|
constructor(columns, opt) {
|
|
super();
|
|
|
|
if (!columns || typeof columns !== `object`) {
|
|
throw new TypeError(`Invalid parameter 'columns' specified.`);
|
|
}
|
|
|
|
opt = assertOptions(opt, [`table`, `inherit`]);
|
|
|
|
if (!npm.utils.isNull(opt.table)) {
|
|
this.table = (opt.table instanceof TableName) ? opt.table : new TableName(opt.table);
|
|
}
|
|
|
|
/**
|
|
* @name helpers.ColumnSet#table
|
|
* @type {helpers.TableName}
|
|
* @readonly
|
|
* @description
|
|
* Destination table. It can be specified for two purposes:
|
|
*
|
|
* - **primary:** to be used as the default table when it is omitted during a call into methods {@link helpers.insert insert} and {@link helpers.update update}
|
|
* - **secondary:** to be automatically written into the console (for logging purposes).
|
|
*/
|
|
|
|
|
|
/**
|
|
* @name helpers.ColumnSet#columns
|
|
* @type helpers.Column[]
|
|
* @readonly
|
|
* @description
|
|
* Array of {@link helpers.Column Column} objects.
|
|
*/
|
|
if (Array.isArray(columns)) {
|
|
const colNames = {};
|
|
this.columns = columns.map(c => {
|
|
const col = (c instanceof Column) ? c : new Column(c);
|
|
if (col.name in colNames) {
|
|
throw new Error(`Duplicate column name "${col.name}".`);
|
|
}
|
|
colNames[col.name] = true;
|
|
return col;
|
|
});
|
|
} else {
|
|
if (columns instanceof Column) {
|
|
this.columns = [columns];
|
|
} else {
|
|
this.columns = [];
|
|
for (const name in columns) {
|
|
if (opt.inherit || Object.prototype.hasOwnProperty.call(columns, name)) {
|
|
this.columns.push(new Column(name));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Object.freeze(this.columns);
|
|
Object.freeze(this);
|
|
|
|
this.extendState({
|
|
names: undefined,
|
|
variables: undefined,
|
|
updates: undefined,
|
|
isSimple: true
|
|
});
|
|
|
|
for (let i = 0; i < this.columns.length; i++) {
|
|
const c = this.columns[i];
|
|
// ColumnSet is simple when the source objects require no preparation,
|
|
// and should be used directly:
|
|
if (c.prop || c.init || `def` in c) {
|
|
this._inner.isSimple = false;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @name helpers.ColumnSet#names
|
|
* @type string
|
|
* @readonly
|
|
* @description
|
|
* Returns a string - comma-separated list of all column names, properly escaped.
|
|
*
|
|
* @example
|
|
* const cs = new ColumnSet(['id^', {name: 'cells', cast: 'int[]'}, 'doc:json']);
|
|
* console.log(cs.names);
|
|
* //=> "id","cells","doc"
|
|
*/
|
|
get names() {
|
|
const _i = this._inner;
|
|
if (!_i.names) {
|
|
_i.names = this.columns.map(c => c.escapedName).join();
|
|
}
|
|
return _i.names;
|
|
}
|
|
|
|
/**
|
|
* @name helpers.ColumnSet#variables
|
|
* @type string
|
|
* @readonly
|
|
* @description
|
|
* Returns a string - formatting template for all column values.
|
|
*
|
|
* @see {@link helpers.ColumnSet#assign assign}
|
|
*
|
|
* @example
|
|
* const cs = new ColumnSet(['id^', {name: 'cells', cast: 'int[]'}, 'doc:json']);
|
|
* console.log(cs.variables);
|
|
* //=> ${id^},${cells}::int[],${doc:json}
|
|
*/
|
|
get variables() {
|
|
const _i = this._inner;
|
|
if (!_i.variables) {
|
|
_i.variables = this.columns.map(c => c.variable + c.castText).join();
|
|
}
|
|
return _i.variables;
|
|
}
|
|
|
|
}
|
|
|
|
/**
|
|
* @method helpers.ColumnSet#assign
|
|
* @description
|
|
* Returns a formatting template of SET assignments, either generic or for a single object.
|
|
*
|
|
* The method is optimized to cache the output string when there are no columns that can be skipped dynamically.
|
|
*
|
|
* This method is primarily for internal use, that's why it does not validate the input.
|
|
*
|
|
* @param {object} [options]
|
|
* Assignment/formatting options.
|
|
*
|
|
* @param {object} [options.source]
|
|
* Source - a single object that contains values for columns.
|
|
*
|
|
* The object is only necessary to correctly apply the logic of skipping columns dynamically, based on the source data
|
|
* and the rules defined in the {@link helpers.ColumnSet ColumnSet}. If, however, you do not care about that, then you do not need to specify any object.
|
|
*
|
|
* Note that even if you do not specify the object, the columns marked as conditional (`cnd: true`) will always be skipped.
|
|
*
|
|
* @param {string} [options.prefix]
|
|
* In cases where needed, an alias prefix to be added before each column.
|
|
*
|
|
* @returns {string}
|
|
* Comma-separated list of variable-to-column assignments.
|
|
*
|
|
* @see {@link helpers.ColumnSet#variables variables}
|
|
*
|
|
* @example
|
|
*
|
|
* const cs = new pgp.helpers.ColumnSet([
|
|
* '?first', // = {name: 'first', cnd: true}
|
|
* 'second:json',
|
|
* {name: 'third', mod: ':raw', cast: 'text'}
|
|
* ]);
|
|
*
|
|
* cs.assign();
|
|
* //=> "second"=${second:json},"third"=${third:raw}::text
|
|
*
|
|
* cs.assign({prefix: 'a b c'});
|
|
* //=> "a b c"."second"=${second:json},"a b c"."third"=${third:raw}::text
|
|
*/
|
|
ColumnSet.prototype.assign = function (options) {
|
|
const _i = this._inner;
|
|
const hasPrefix = options && options.prefix && typeof options.prefix === `string`;
|
|
if (_i.updates && !hasPrefix) {
|
|
return _i.updates;
|
|
}
|
|
let dynamic = hasPrefix;
|
|
const hasSource = options && options.source && typeof options.source === `object`;
|
|
let list = this.columns.filter(c => {
|
|
if (c.cnd) {
|
|
return false;
|
|
}
|
|
if (c.skip) {
|
|
dynamic = true;
|
|
if (hasSource) {
|
|
const a = colDesc(c, options.source);
|
|
if (c.skip.call(options.source, a)) {
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
return true;
|
|
});
|
|
|
|
const prefix = hasPrefix ? npm.formatting.as.alias(options.prefix) + `.` : ``;
|
|
list = list.map(c => prefix + c.escapedName + `=` + c.variable + c.castText).join();
|
|
|
|
if (!dynamic) {
|
|
_i.updates = list;
|
|
}
|
|
return list;
|
|
};
|
|
|
|
/**
|
|
* @method helpers.ColumnSet#assignColumns
|
|
* @description
|
|
* Generates assignments for all columns in the set, with support for aliases and column-skipping logic.
|
|
* Aliases are set by using method {@link formatting.alias as.alias}.
|
|
*
|
|
* @param {{}} [options]
|
|
* Optional Parameters.
|
|
*
|
|
* @param {string} [options.from]
|
|
* Alias for the source columns.
|
|
*
|
|
* @param {string} [options.to]
|
|
* Alias for the destination columns.
|
|
*
|
|
* @param {string | Array<string> | function} [options.skip]
|
|
* Name(s) of the column(s) to be skipped (case-sensitive). It can be either a single string or an array of strings.
|
|
*
|
|
* It can also be a function - iterator, to be called for every column, passing in {@link helpers.Column Column} as
|
|
* `this` context, and plus as a single parameter. The function would return a truthy value for every column that needs to be skipped.
|
|
*
|
|
* @returns {string}
|
|
* A string of comma-separated column assignments.
|
|
*
|
|
* @example
|
|
*
|
|
* const cs = new pgp.helpers.ColumnSet(['id', 'city', 'street']);
|
|
*
|
|
* cs.assignColumns({from: 'EXCLUDED', skip: 'id'})
|
|
* //=> "city"=EXCLUDED."city","street"=EXCLUDED."street"
|
|
*
|
|
* @example
|
|
*
|
|
* const cs = new pgp.helpers.ColumnSet(['?id', 'city', 'street']);
|
|
*
|
|
* cs.assignColumns({from: 'source', to: 'target', skip: c => c.cnd})
|
|
* //=> target."city"=source."city",target."street"=source."street"
|
|
*
|
|
*/
|
|
ColumnSet.prototype.assignColumns = function (options) {
|
|
options = assertOptions(options, [`from`, `to`, `skip`]);
|
|
const skip = (typeof options.skip === `string` && [options.skip]) || ((Array.isArray(options.skip) || typeof options.skip === `function`) && options.skip);
|
|
const from = (typeof options.from === `string` && options.from && (npm.formatting.as.alias(options.from) + `.`)) || ``;
|
|
const to = (typeof options.to === `string` && options.to && (npm.formatting.as.alias(options.to) + `.`)) || ``;
|
|
const iterator = typeof skip === `function` ? c => !skip.call(c, c) : c => skip.indexOf(c.name) === -1;
|
|
const cols = skip ? this.columns.filter(iterator) : this.columns;
|
|
return cols.map(c => to + c.escapedName + `=` + from + c.escapedName).join();
|
|
};
|
|
|
|
/**
|
|
* @method helpers.ColumnSet#extend
|
|
* @description
|
|
* Creates a new {@link helpers.ColumnSet ColumnSet}, by joining the two sets of columns.
|
|
*
|
|
* If the two sets contain a column with the same `name` (case-sensitive), an error is thrown.
|
|
*
|
|
* @param {helpers.Column|helpers.ColumnSet|array} columns
|
|
* Columns to be appended, of the same type as parameter `columns` during {@link helpers.ColumnSet ColumnSet} construction, except:
|
|
* - it can also be of type {@link helpers.ColumnSet ColumnSet}
|
|
* - it cannot be a simple object (properties enumeration is not supported here)
|
|
*
|
|
* @returns {helpers.ColumnSet}
|
|
* New {@link helpers.ColumnSet ColumnSet} object with the extended/concatenated list of columns.
|
|
*
|
|
* @see
|
|
* {@link helpers.Column Column},
|
|
* {@link helpers.ColumnSet#merge merge}
|
|
*
|
|
* @example
|
|
*
|
|
* const pgp = require('pg-promise')();
|
|
*
|
|
* const cs = new pgp.helpers.ColumnSet(['one', 'two'], {table: 'my-table'});
|
|
* console.log(cs);
|
|
* //=>
|
|
* // ColumnSet {
|
|
* // table: "my-table"
|
|
* // columns: [
|
|
* // Column {
|
|
* // name: "one"
|
|
* // }
|
|
* // Column {
|
|
* // name: "two"
|
|
* // }
|
|
* // ]
|
|
* // }
|
|
* const csExtended = cs.extend(['three']);
|
|
* console.log(csExtended);
|
|
* //=>
|
|
* // ColumnSet {
|
|
* // table: "my-table"
|
|
* // columns: [
|
|
* // Column {
|
|
* // name: "one"
|
|
* // }
|
|
* // Column {
|
|
* // name: "two"
|
|
* // }
|
|
* // Column {
|
|
* // name: "three"
|
|
* // }
|
|
* // ]
|
|
* // }
|
|
*/
|
|
ColumnSet.prototype.extend = function (columns) {
|
|
let cs = columns;
|
|
if (!(cs instanceof ColumnSet)) {
|
|
cs = new ColumnSet(columns);
|
|
}
|
|
// Any duplicate column will throw Error = 'Duplicate column name "name".',
|
|
return new ColumnSet(this.columns.concat(cs.columns), {table: this.table});
|
|
};
|
|
|
|
/**
|
|
* @method helpers.ColumnSet#merge
|
|
* @description
|
|
* Creates a new {@link helpers.ColumnSet ColumnSet}, by joining the two sets of columns.
|
|
*
|
|
* Items in `columns` with the same `name` (case-sensitive) override the original columns.
|
|
*
|
|
* @param {helpers.Column|helpers.ColumnSet|array} columns
|
|
* Columns to be appended, of the same type as parameter `columns` during {@link helpers.ColumnSet ColumnSet} construction, except:
|
|
* - it can also be of type {@link helpers.ColumnSet ColumnSet}
|
|
* - it cannot be a simple object (properties enumeration is not supported here)
|
|
*
|
|
* @see
|
|
* {@link helpers.Column Column},
|
|
* {@link helpers.ColumnSet#extend extend}
|
|
*
|
|
* @returns {helpers.ColumnSet}
|
|
* New {@link helpers.ColumnSet ColumnSet} object with the merged list of columns.
|
|
*
|
|
* @example
|
|
*
|
|
* const pgp = require('pg-promise')();
|
|
*
|
|
* const cs = new pgp.helpers.ColumnSet(['?one', 'two:json'], {table: 'my-table'});
|
|
* console.log(cs);
|
|
* //=>
|
|
* // ColumnSet {
|
|
* // table: "my-table"
|
|
* // columns: [
|
|
* // Column {
|
|
* // name: "one"
|
|
* // cnd: true
|
|
* // }
|
|
* // Column {
|
|
* // name: "two"
|
|
* // mod: ":json"
|
|
* // }
|
|
* // ]
|
|
* // }
|
|
* const csMerged = cs.merge(['two', 'three^']);
|
|
* console.log(csMerged);
|
|
* //=>
|
|
* // ColumnSet {
|
|
* // table: "my-table"
|
|
* // columns: [
|
|
* // Column {
|
|
* // name: "one"
|
|
* // cnd: true
|
|
* // }
|
|
* // Column {
|
|
* // name: "two"
|
|
* // }
|
|
* // Column {
|
|
* // name: "three"
|
|
* // mod: "^"
|
|
* // }
|
|
* // ]
|
|
* // }
|
|
*
|
|
*/
|
|
ColumnSet.prototype.merge = function (columns) {
|
|
let cs = columns;
|
|
if (!(cs instanceof ColumnSet)) {
|
|
cs = new ColumnSet(columns);
|
|
}
|
|
const colNames = {}, cols = [];
|
|
this.columns.forEach((c, idx) => {
|
|
cols.push(c);
|
|
colNames[c.name] = idx;
|
|
});
|
|
cs.columns.forEach(c => {
|
|
if (c.name in colNames) {
|
|
cols[colNames[c.name]] = c;
|
|
} else {
|
|
cols.push(c);
|
|
}
|
|
});
|
|
return new ColumnSet(cols, {table: this.table});
|
|
};
|
|
|
|
/**
|
|
* @method helpers.ColumnSet#prepare
|
|
* @description
|
|
* Prepares a source object to be formatted, by cloning it and applying the rules as set by the
|
|
* columns configuration.
|
|
*
|
|
* This method is primarily for internal use, that's why it does not validate the input parameters.
|
|
*
|
|
* @param {object} source
|
|
* The source object to be prepared, if required.
|
|
*
|
|
* It must be a non-`null` object, which the method does not validate, as it is
|
|
* intended primarily for internal use by the library.
|
|
*
|
|
* @returns {object}
|
|
* When the object needs to be prepared, the method returns a clone of the source object,
|
|
* with all properties and values set according to the columns configuration.
|
|
*
|
|
* When the object does not need to be prepared, the original object is returned.
|
|
*/
|
|
ColumnSet.prototype.prepare = function (source) {
|
|
if (this._inner.isSimple) {
|
|
return source; // a simple ColumnSet requires no object preparation;
|
|
}
|
|
const target = {};
|
|
this.columns.forEach(c => {
|
|
const a = colDesc(c, source);
|
|
if (c.init) {
|
|
target[a.name] = c.init.call(source, a);
|
|
} else {
|
|
if (a.exists || `def` in c) {
|
|
target[a.name] = a.value;
|
|
}
|
|
}
|
|
});
|
|
return target;
|
|
};
|
|
|
|
function colDesc(column, source) {
|
|
const a = {
|
|
source,
|
|
name: column.prop || column.name
|
|
};
|
|
a.exists = a.name in source;
|
|
if (a.exists) {
|
|
a.value = source[a.name];
|
|
} else {
|
|
a.value = `def` in column ? column.def : undefined;
|
|
}
|
|
return a;
|
|
}
|
|
|
|
/**
|
|
* @method helpers.ColumnSet#toString
|
|
* @description
|
|
* Creates a well-formatted multi-line string that represents the object.
|
|
*
|
|
* It is called automatically when writing the object into the console.
|
|
*
|
|
* @param {number} [level=0]
|
|
* Nested output level, to provide visual offset.
|
|
*
|
|
* @returns {string}
|
|
*/
|
|
ColumnSet.prototype.toString = function (level) {
|
|
level = level > 0 ? parseInt(level) : 0;
|
|
const gap0 = npm.utils.messageGap(level),
|
|
gap1 = npm.utils.messageGap(level + 1),
|
|
lines = [
|
|
`ColumnSet {`
|
|
];
|
|
if (this.table) {
|
|
lines.push(gap1 + `table: ` + this.table);
|
|
}
|
|
if (this.columns.length) {
|
|
lines.push(gap1 + `columns: [`);
|
|
this.columns.forEach(c => {
|
|
lines.push(c.toString(2));
|
|
});
|
|
lines.push(gap1 + `]`);
|
|
} else {
|
|
lines.push(gap1 + `columns: []`);
|
|
}
|
|
lines.push(gap0 + `}`);
|
|
return lines.join(npm.os.EOL);
|
|
};
|
|
|
|
npm.utils.addInspection(ColumnSet, function () {
|
|
return this.toString();
|
|
});
|
|
|
|
module.exports = {ColumnSet};
|
|
|