column-set.js 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649
  1. /*
  2. * Copyright (c) 2015-present, Vitaly Tomilov
  3. *
  4. * See the LICENSE file at the top-level directory of this distribution
  5. * for licensing information.
  6. *
  7. * Removal or modification of this copyright notice is prohibited.
  8. */
  9. const {InnerState} = require('../inner-state');
  10. const {assert} = require('../assert');
  11. const {TableName} = require('./table-name');
  12. const {Column} = require('./column');
  13. const npm = {
  14. os: require('os'),
  15. utils: require('../utils'),
  16. formatting: require('../formatting')
  17. };
  18. /**
  19. * @class helpers.ColumnSet
  20. * @description
  21. * Performance-optimized, read-only structure with query-formatting columns.
  22. *
  23. * In order to avail from performance optimization provided by this class, it should be created
  24. * only once, statically, and then reused.
  25. *
  26. * @param {object|helpers.Column|array} columns
  27. * Columns information object, depending on the type:
  28. *
  29. * - When it is a simple object, its properties are enumerated to represent both column names and property names
  30. * within the source objects. See also option `inherit` that's applicable in this case.
  31. *
  32. * - When it is a single {@link helpers.Column Column} object, property {@link helpers.ColumnSet#columns columns} is initialized with
  33. * just a single column. It is not a unique situation when only a single column is required for an update operation.
  34. *
  35. * - 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},
  36. * it is used directly; otherwise the element is passed into {@link helpers.Column Column} constructor for initialization.
  37. * On any duplicate column name (case-sensitive) it will throw {@link external:Error Error} = `Duplicate column name "name".`
  38. *
  39. * - When it is none of the above, it will throw {@link external:TypeError TypeError} = `Invalid parameter 'columns' specified.`
  40. *
  41. * @param {object} [options]
  42. *
  43. * @param {helpers.TableName|Table|string} [options.table]
  44. * Table details.
  45. *
  46. * 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.
  47. *
  48. * It can be used as the default for methods {@link helpers.insert insert} and {@link helpers.update update} when their parameter
  49. * `table` is omitted, and for logging purposes.
  50. *
  51. * @param {boolean} [options.inherit = false]
  52. * Use inherited properties in addition to the object's own properties.
  53. *
  54. * By default, only the object's own properties are enumerated for column names.
  55. *
  56. * @returns {helpers.ColumnSet}
  57. *
  58. * @see
  59. *
  60. * {@link helpers.ColumnSet#columns columns},
  61. * {@link helpers.ColumnSet#names names},
  62. * {@link helpers.ColumnSet#table table},
  63. * {@link helpers.ColumnSet#variables variables} |
  64. * {@link helpers.ColumnSet#assign assign},
  65. * {@link helpers.ColumnSet#assignColumns assignColumns},
  66. * {@link helpers.ColumnSet#extend extend},
  67. * {@link helpers.ColumnSet#merge merge},
  68. * {@link helpers.ColumnSet#prepare prepare}
  69. *
  70. * @example
  71. *
  72. * // A complex insert/update object scenario for table 'purchases' in schema 'fiscal'.
  73. * // For a good performance, you should declare such objects once and then reuse them.
  74. * //
  75. * // Column Requirements:
  76. * //
  77. * // 1. Property 'id' is only to be used for a WHERE condition in updates
  78. * // 2. Property 'list' needs to be formatted as a csv
  79. * // 3. Property 'code' is to be used as raw text, and to be defaulted to 0 when the
  80. * // property is missing in the source object
  81. * // 4. Property 'log' is a JSON object with 'log-entry' for the column name
  82. * // 5. Property 'data' requires SQL type casting '::int[]'
  83. * // 6. Property 'amount' needs to be set to 100, if it is 0
  84. * // 7. Property 'total' must be skipped during updates, if 'amount' was 0, plus its
  85. * // column name is 'total-val'
  86. * const {ColumnSet} = pgp.helpers;
  87. *
  88. * const cs = new ColumnSet([
  89. * '?id', // ColumnConfig equivalent: {name: 'id', cnd: true}
  90. * 'list:csv', // ColumnConfig equivalent: {name: 'list', mod: ':csv'}
  91. * {
  92. * name: 'code',
  93. * mod: '^', // format as raw text
  94. * def: 0 // default to 0 when the property doesn't exist
  95. * },
  96. * {
  97. * name: 'log-entry',
  98. * prop: 'log',
  99. * mod: ':json' // format as JSON
  100. * },
  101. * {
  102. * name: 'data',
  103. * cast: 'int[]' // use SQL type casting '::int[]'
  104. * },
  105. * {
  106. * name: 'amount',
  107. * init(col) {
  108. * // set to 100, if the value is 0:
  109. * return col.value === 0 ? 100 : col.value;
  110. * }
  111. * },
  112. * {
  113. * name: 'total-val',
  114. * prop: 'total',
  115. * skip(col) {
  116. * // skip from updates, if 'amount' is 0:
  117. * return col.source.amount === 0;
  118. * }
  119. * }
  120. * ], {table: {table: 'purchases', schema: 'fiscal'}});
  121. *
  122. * // Alternatively, you could take the table declaration out:
  123. * // const table = new pgp.helpers.TableName('purchases', 'fiscal');
  124. *
  125. * console.log(cs); // console output for the object:
  126. * //=>
  127. * // ColumnSet {
  128. * // table: "fiscal"."purchases"
  129. * // columns: [
  130. * // Column {
  131. * // name: "id"
  132. * // cnd: true
  133. * // }
  134. * // Column {
  135. * // name: "list"
  136. * // mod: ":csv"
  137. * // }
  138. * // Column {
  139. * // name: "code"
  140. * // mod: "^"
  141. * // def: 0
  142. * // }
  143. * // Column {
  144. * // name: "log-entry"
  145. * // prop: "log"
  146. * // mod: ":json"
  147. * // }
  148. * // Column {
  149. * // name: "data"
  150. * // cast: "int[]"
  151. * // }
  152. * // Column {
  153. * // name: "amount"
  154. * // init: [Function]
  155. * // }
  156. * // Column {
  157. * // name: "total-val"
  158. * // prop: "total"
  159. * // skip: [Function]
  160. * // }
  161. * // ]
  162. * // }
  163. */
  164. class ColumnSet extends InnerState {
  165. constructor(columns, opt) {
  166. super();
  167. if (!columns || typeof columns !== 'object') {
  168. throw new TypeError('Invalid parameter \'columns\' specified.');
  169. }
  170. opt = assert(opt, ['table', 'inherit']);
  171. if (!npm.utils.isNull(opt.table)) {
  172. this.table = (opt.table instanceof TableName) ? opt.table : new TableName(opt.table);
  173. }
  174. /**
  175. * @name helpers.ColumnSet#table
  176. * @type {helpers.TableName}
  177. * @readonly
  178. * @description
  179. * Destination table. It can be specified for two purposes:
  180. *
  181. * - **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}
  182. * - **secondary:** to be automatically written into the console (for logging purposes).
  183. */
  184. /**
  185. * @name helpers.ColumnSet#columns
  186. * @type helpers.Column[]
  187. * @readonly
  188. * @description
  189. * Array of {@link helpers.Column Column} objects.
  190. */
  191. if (Array.isArray(columns)) {
  192. const colNames = {};
  193. this.columns = columns.map(c => {
  194. const col = (c instanceof Column) ? c : new Column(c);
  195. if (col.name in colNames) {
  196. throw new Error(`Duplicate column name "${col.name}".`);
  197. }
  198. colNames[col.name] = true;
  199. return col;
  200. });
  201. } else {
  202. if (columns instanceof Column) {
  203. this.columns = [columns];
  204. } else {
  205. this.columns = [];
  206. for (const name in columns) {
  207. if (opt.inherit || Object.prototype.hasOwnProperty.call(columns, name)) {
  208. this.columns.push(new Column(name));
  209. }
  210. }
  211. }
  212. }
  213. Object.freeze(this.columns);
  214. Object.freeze(this);
  215. this.extendState({
  216. names: undefined,
  217. variables: undefined,
  218. updates: undefined,
  219. isSimple: true
  220. });
  221. for (let i = 0; i < this.columns.length; i++) {
  222. const c = this.columns[i];
  223. // ColumnSet is simple when the source objects require no preparation,
  224. // and should be used directly:
  225. if (c.prop || c.init || 'def' in c) {
  226. this._inner.isSimple = false;
  227. break;
  228. }
  229. }
  230. }
  231. /**
  232. * @name helpers.ColumnSet#names
  233. * @type string
  234. * @readonly
  235. * @description
  236. * Returns a string - comma-separated list of all column names, properly escaped.
  237. *
  238. * @example
  239. * const cs = new ColumnSet(['id^', {name: 'cells', cast: 'int[]'}, 'doc:json']);
  240. * console.log(cs.names);
  241. * //=> "id","cells","doc"
  242. */
  243. get names() {
  244. const _i = this._inner;
  245. if (!_i.names) {
  246. _i.names = this.columns.map(c => c.escapedName).join();
  247. }
  248. return _i.names;
  249. }
  250. /**
  251. * @name helpers.ColumnSet#variables
  252. * @type string
  253. * @readonly
  254. * @description
  255. * Returns a string - formatting template for all column values.
  256. *
  257. * @see {@link helpers.ColumnSet#assign assign}
  258. *
  259. * @example
  260. * const cs = new ColumnSet(['id^', {name: 'cells', cast: 'int[]'}, 'doc:json']);
  261. * console.log(cs.variables);
  262. * //=> ${id^},${cells}::int[],${doc:json}
  263. */
  264. get variables() {
  265. const _i = this._inner;
  266. if (!_i.variables) {
  267. _i.variables = this.columns.map(c => c.variable + c.castText).join();
  268. }
  269. return _i.variables;
  270. }
  271. }
  272. /**
  273. * @method helpers.ColumnSet#assign
  274. * @description
  275. * Returns a formatting template of SET assignments, either generic or for a single object.
  276. *
  277. * The method is optimized to cache the output string when there are no columns that can be skipped dynamically.
  278. *
  279. * This method is primarily for internal use, that's why it does not validate the input.
  280. *
  281. * @param {object} [options]
  282. * Assignment/formatting options.
  283. *
  284. * @param {object} [options.source]
  285. * Source - a single object that contains values for columns.
  286. *
  287. * The object is only necessary to correctly apply the logic of skipping columns dynamically, based on the source data
  288. * 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.
  289. *
  290. * Note that even if you do not specify the object, the columns marked as conditional (`cnd: true`) will always be skipped.
  291. *
  292. * @param {string} [options.prefix]
  293. * In cases where needed, an alias prefix to be added before each column.
  294. *
  295. * @returns {string}
  296. * Comma-separated list of variable-to-column assignments.
  297. *
  298. * @see {@link helpers.ColumnSet#variables variables}
  299. *
  300. * @example
  301. *
  302. * const cs = new pgp.helpers.ColumnSet([
  303. * '?first', // = {name: 'first', cnd: true}
  304. * 'second:json',
  305. * {name: 'third', mod: ':raw', cast: 'text'}
  306. * ]);
  307. *
  308. * cs.assign();
  309. * //=> "second"=${second:json},"third"=${third:raw}::text
  310. *
  311. * cs.assign({prefix: 'a b c'});
  312. * //=> "a b c"."second"=${second:json},"a b c"."third"=${third:raw}::text
  313. */
  314. ColumnSet.prototype.assign = function (options) {
  315. const _i = this._inner;
  316. const hasPrefix = options && options.prefix && typeof options.prefix === 'string';
  317. if (_i.updates && !hasPrefix) {
  318. return _i.updates;
  319. }
  320. let dynamic = hasPrefix;
  321. const hasSource = options && options.source && typeof options.source === 'object';
  322. let list = this.columns.filter(c => {
  323. if (c.cnd) {
  324. return false;
  325. }
  326. if (c.skip) {
  327. dynamic = true;
  328. if (hasSource) {
  329. const a = colDesc(c, options.source);
  330. if (c.skip.call(options.source, a)) {
  331. return false;
  332. }
  333. }
  334. }
  335. return true;
  336. });
  337. const prefix = hasPrefix ? npm.formatting.as.alias(options.prefix) + '.' : '';
  338. list = list.map(c => prefix + c.escapedName + '=' + c.variable + c.castText).join();
  339. if (!dynamic) {
  340. _i.updates = list;
  341. }
  342. return list;
  343. };
  344. /**
  345. * @method helpers.ColumnSet#assignColumns
  346. * @description
  347. * Generates assignments for all columns in the set, with support for aliases and column-skipping logic.
  348. * Aliases are set by using method {@link formatting.alias as.alias}.
  349. *
  350. * @param {{}} [options]
  351. * Optional Parameters.
  352. *
  353. * @param {string} [options.from]
  354. * Alias for the source columns.
  355. *
  356. * @param {string} [options.to]
  357. * Alias for the destination columns.
  358. *
  359. * @param {string | Array<string> | function} [options.skip]
  360. * Name(s) of the column(s) to be skipped (case-sensitive). It can be either a single string or an array of strings.
  361. *
  362. * It can also be a function - iterator, to be called for every column, passing in {@link helpers.Column Column} as
  363. * `this` context, and plus as a single parameter. The function would return a truthy value for every column that needs to be skipped.
  364. *
  365. * @returns {string}
  366. * A string of comma-separated column assignments.
  367. *
  368. * @example
  369. *
  370. * const cs = new ColumnSet(['id', 'city', 'street']);
  371. *
  372. * cs.assignColumns({from: 'EXCLUDED', skip: 'id'})
  373. * //=> "city"=EXCLUDED."city","street"=EXCLUDED."street"
  374. *
  375. * @example
  376. *
  377. * const cs = new ColumnSet(['?id', 'city', 'street']);
  378. *
  379. * cs.assignColumns({from: 'source', to: 'target', skip: c => c.cnd})
  380. * //=> target."city"=source."city",target."street"=source."street"
  381. *
  382. */
  383. ColumnSet.prototype.assignColumns = function (options) {
  384. options = assert(options, ['from', 'to', 'skip']);
  385. const skip = (typeof options.skip === 'string' && [options.skip]) || ((Array.isArray(options.skip) || typeof options.skip === 'function') && options.skip);
  386. const from = (typeof options.from === 'string' && options.from && (npm.formatting.as.alias(options.from) + '.')) || '';
  387. const to = (typeof options.to === 'string' && options.to && (npm.formatting.as.alias(options.to) + '.')) || '';
  388. const iterator = typeof skip === 'function' ? c => !skip.call(c, c) : c => skip.indexOf(c.name) === -1;
  389. const cols = skip ? this.columns.filter(iterator) : this.columns;
  390. return cols.map(c => to + c.escapedName + '=' + from + c.escapedName).join();
  391. };
  392. /**
  393. * @method helpers.ColumnSet#extend
  394. * @description
  395. * Creates a new {@link helpers.ColumnSet ColumnSet}, by joining the two sets of columns.
  396. *
  397. * If the two sets contain a column with the same `name` (case-sensitive), an error is thrown.
  398. *
  399. * @param {helpers.Column|helpers.ColumnSet|array} columns
  400. * Columns to be appended, of the same type as parameter `columns` during {@link helpers.ColumnSet ColumnSet} construction, except:
  401. * - it can also be of type {@link helpers.ColumnSet ColumnSet}
  402. * - it cannot be a simple object (properties enumeration is not supported here)
  403. *
  404. * @returns {helpers.ColumnSet}
  405. * New {@link helpers.ColumnSet ColumnSet} object with the extended/concatenated list of columns.
  406. *
  407. * @see
  408. * {@link helpers.Column Column},
  409. * {@link helpers.ColumnSet#merge merge}
  410. *
  411. * @example
  412. *
  413. * const pgp = require('pg-promise')();
  414. *
  415. * const cs = new pgp.helpers.ColumnSet(['one', 'two'], {table: 'my-table'});
  416. * console.log(cs);
  417. * //=>
  418. * // ColumnSet {
  419. * // table: "my-table"
  420. * // columns: [
  421. * // Column {
  422. * // name: "one"
  423. * // }
  424. * // Column {
  425. * // name: "two"
  426. * // }
  427. * // ]
  428. * // }
  429. * const csExtended = cs.extend(['three']);
  430. * console.log(csExtended);
  431. * //=>
  432. * // ColumnSet {
  433. * // table: "my-table"
  434. * // columns: [
  435. * // Column {
  436. * // name: "one"
  437. * // }
  438. * // Column {
  439. * // name: "two"
  440. * // }
  441. * // Column {
  442. * // name: "three"
  443. * // }
  444. * // ]
  445. * // }
  446. */
  447. ColumnSet.prototype.extend = function (columns) {
  448. let cs = columns;
  449. if (!(cs instanceof ColumnSet)) {
  450. cs = new ColumnSet(columns);
  451. }
  452. // Any duplicate column will throw Error = 'Duplicate column name "name".',
  453. return new ColumnSet(this.columns.concat(cs.columns), {table: this.table});
  454. };
  455. /**
  456. * @method helpers.ColumnSet#merge
  457. * @description
  458. * Creates a new {@link helpers.ColumnSet ColumnSet}, by joining the two sets of columns.
  459. *
  460. * Items in `columns` with the same `name` (case-sensitive) override the original columns.
  461. *
  462. * @param {helpers.Column|helpers.ColumnSet|array} columns
  463. * Columns to be appended, of the same type as parameter `columns` during {@link helpers.ColumnSet ColumnSet} construction, except:
  464. * - it can also be of type {@link helpers.ColumnSet ColumnSet}
  465. * - it cannot be a simple object (properties enumeration is not supported here)
  466. *
  467. * @see
  468. * {@link helpers.Column Column},
  469. * {@link helpers.ColumnSet#extend extend}
  470. *
  471. * @returns {helpers.ColumnSet}
  472. * New {@link helpers.ColumnSet ColumnSet} object with the merged list of columns.
  473. *
  474. * @example
  475. *
  476. * const pgp = require('pg-promise')();
  477. * const {ColumnSet} = pgp.helpers;
  478. *
  479. * const cs = new ColumnSet(['?one', 'two:json'], {table: 'my-table'});
  480. * console.log(cs);
  481. * //=>
  482. * // ColumnSet {
  483. * // table: "my-table"
  484. * // columns: [
  485. * // Column {
  486. * // name: "one"
  487. * // cnd: true
  488. * // }
  489. * // Column {
  490. * // name: "two"
  491. * // mod: ":json"
  492. * // }
  493. * // ]
  494. * // }
  495. * const csMerged = cs.merge(['two', 'three^']);
  496. * console.log(csMerged);
  497. * //=>
  498. * // ColumnSet {
  499. * // table: "my-table"
  500. * // columns: [
  501. * // Column {
  502. * // name: "one"
  503. * // cnd: true
  504. * // }
  505. * // Column {
  506. * // name: "two"
  507. * // }
  508. * // Column {
  509. * // name: "three"
  510. * // mod: "^"
  511. * // }
  512. * // ]
  513. * // }
  514. *
  515. */
  516. ColumnSet.prototype.merge = function (columns) {
  517. let cs = columns;
  518. if (!(cs instanceof ColumnSet)) {
  519. cs = new ColumnSet(columns);
  520. }
  521. const colNames = {}, cols = [];
  522. this.columns.forEach((c, idx) => {
  523. cols.push(c);
  524. colNames[c.name] = idx;
  525. });
  526. cs.columns.forEach(c => {
  527. if (c.name in colNames) {
  528. cols[colNames[c.name]] = c;
  529. } else {
  530. cols.push(c);
  531. }
  532. });
  533. return new ColumnSet(cols, {table: this.table});
  534. };
  535. /**
  536. * @method helpers.ColumnSet#prepare
  537. * @description
  538. * Prepares a source object to be formatted, by cloning it and applying the rules as set by the
  539. * columns configuration.
  540. *
  541. * This method is primarily for internal use, that's why it does not validate the input parameters.
  542. *
  543. * @param {object} source
  544. * The source object to be prepared, if required.
  545. *
  546. * It must be a non-`null` object, which the method does not validate, as it is
  547. * intended primarily for internal use by the library.
  548. *
  549. * @returns {object}
  550. * When the object needs to be prepared, the method returns a clone of the source object,
  551. * with all properties and values set according to the columns configuration.
  552. *
  553. * When the object does not need to be prepared, the original object is returned.
  554. */
  555. ColumnSet.prototype.prepare = function (source) {
  556. if (this._inner.isSimple) {
  557. return source; // a simple ColumnSet requires no object preparation;
  558. }
  559. const target = {};
  560. this.columns.forEach(c => {
  561. const a = colDesc(c, source);
  562. if (c.init) {
  563. target[a.name] = c.init.call(source, a);
  564. } else {
  565. if (a.exists || 'def' in c) {
  566. target[a.name] = a.value;
  567. }
  568. }
  569. });
  570. return target;
  571. };
  572. function colDesc(column, source) {
  573. const a = {
  574. source,
  575. name: column.prop || column.name
  576. };
  577. a.exists = a.name in source;
  578. if (a.exists) {
  579. a.value = source[a.name];
  580. } else {
  581. a.value = 'def' in column ? column.def : undefined;
  582. }
  583. return a;
  584. }
  585. /**
  586. * @method helpers.ColumnSet#toString
  587. * @description
  588. * Creates a well-formatted multi-line string that represents the object.
  589. *
  590. * It is called automatically when writing the object into the console.
  591. *
  592. * @param {number} [level=0]
  593. * Nested output level, to provide visual offset.
  594. *
  595. * @returns {string}
  596. */
  597. ColumnSet.prototype.toString = function (level) {
  598. level = level > 0 ? parseInt(level) : 0;
  599. const gap0 = npm.utils.messageGap(level),
  600. gap1 = npm.utils.messageGap(level + 1),
  601. lines = [
  602. 'ColumnSet {'
  603. ];
  604. if (this.table) {
  605. lines.push(gap1 + 'table: ' + this.table);
  606. }
  607. if (this.columns.length) {
  608. lines.push(gap1 + 'columns: [');
  609. this.columns.forEach(c => {
  610. lines.push(c.toString(2));
  611. });
  612. lines.push(gap1 + ']');
  613. } else {
  614. lines.push(gap1 + 'columns: []');
  615. }
  616. lines.push(gap0 + '}');
  617. return lines.join(npm.os.EOL);
  618. };
  619. npm.utils.addInspection(ColumnSet, function () {
  620. return this.toString();
  621. });
  622. module.exports = {ColumnSet};