postgresql-query.js 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648
  1. /**
  2. * @node postgresql-query
  3. * @name PostgreSQL Query
  4. * @category database
  5. * @version 1.0.0
  6. * @description Execute CRUD operations on PostgreSQL databases with parameterized queries
  7. * @icon database
  8. */
  9. const configSchema = {
  10. type: 'object',
  11. properties: {
  12. credentialId: {
  13. type: 'string',
  14. title: 'PostgreSQL Credential',
  15. description: 'Select the PostgreSQL credential to use for connection',
  16. dynamicOptions: {
  17. source: 'credentials',
  18. filter: { type: 'postgresql' }
  19. }
  20. },
  21. database: {
  22. type: 'string',
  23. title: 'Database',
  24. description: 'Database name (overrides credential default if set)'
  25. },
  26. table: {
  27. type: 'string',
  28. title: 'Table',
  29. description: 'Table name for query operations'
  30. },
  31. operation: {
  32. type: 'string',
  33. title: 'Operation',
  34. description: 'Type of operation to perform',
  35. enum: ['select', 'insert', 'update', 'delete', 'upsert', 'bulkInsert', 'customSql'],
  36. enumLabels: ['Select', 'Insert', 'Update', 'Delete', 'Upsert', 'Bulk Insert', 'Custom SQL'],
  37. default: 'select'
  38. },
  39. columns: {
  40. type: 'array',
  41. title: 'Columns',
  42. description: 'Columns to select (empty for all)',
  43. items: {
  44. type: 'string'
  45. },
  46. showWhen: { field: 'operation', value: 'select' }
  47. },
  48. whereConditions: {
  49. type: 'array',
  50. title: 'WHERE Conditions',
  51. description: 'Filter conditions for the query',
  52. items: {
  53. type: 'object',
  54. properties: {
  55. column: { type: 'string', title: 'Column' },
  56. operator: {
  57. type: 'string',
  58. title: 'Operator',
  59. enum: ['=', '!=', '<', '>', '<=', '>=', 'LIKE', 'ILIKE', 'NOT LIKE', 'IN', 'NOT IN', 'IS NULL', 'IS NOT NULL'],
  60. default: '='
  61. },
  62. value: { type: 'string', title: 'Value' }
  63. }
  64. },
  65. showWhen: { field: 'operation', valueIn: ['select', 'update', 'delete'] }
  66. },
  67. orderBy: {
  68. type: 'array',
  69. title: 'ORDER BY',
  70. description: 'Sorting options',
  71. items: {
  72. type: 'object',
  73. properties: {
  74. column: { type: 'string', title: 'Column' },
  75. direction: {
  76. type: 'string',
  77. title: 'Direction',
  78. enum: ['ASC', 'DESC'],
  79. default: 'ASC'
  80. }
  81. }
  82. },
  83. showWhen: { field: 'operation', value: 'select' }
  84. },
  85. limit: {
  86. type: 'number',
  87. title: 'Limit',
  88. description: 'Maximum number of rows to return (0 for no limit)',
  89. default: 100,
  90. showWhen: { field: 'operation', value: 'select' }
  91. },
  92. offset: {
  93. type: 'number',
  94. title: 'Offset',
  95. description: 'Number of rows to skip',
  96. default: 0,
  97. showWhen: { field: 'operation', value: 'select' }
  98. },
  99. insertData: {
  100. type: 'string',
  101. title: 'Data to Insert (JSON)',
  102. description: 'JSON object with column:value pairs. Supports {{variable}} interpolation.',
  103. showWhen: { field: 'operation', value: 'insert' }
  104. },
  105. updateData: {
  106. type: 'string',
  107. title: 'Data to Update (JSON)',
  108. description: 'JSON object with column:value pairs to set. Supports {{variable}} interpolation.',
  109. showWhen: { field: 'operation', value: 'update' }
  110. },
  111. upsertData: {
  112. type: 'string',
  113. title: 'Data to Upsert (JSON)',
  114. description: 'JSON object with column:value pairs. Supports {{variable}} interpolation.',
  115. showWhen: { field: 'operation', value: 'upsert' }
  116. },
  117. conflictKey: {
  118. type: 'array',
  119. title: 'Conflict Key Columns',
  120. description: 'Columns that form the unique/primary key for conflict detection',
  121. items: {
  122. type: 'string'
  123. },
  124. showWhen: { field: 'operation', value: 'upsert' }
  125. },
  126. updateOnConflict: {
  127. type: 'array',
  128. title: 'Update Columns on Conflict',
  129. description: 'Columns to update when a conflict is detected (empty for all non-key columns)',
  130. items: {
  131. type: 'string'
  132. },
  133. showWhen: { field: 'operation', value: 'upsert' }
  134. },
  135. bulkData: {
  136. type: 'string',
  137. title: 'Bulk Data (JSON Array)',
  138. description: 'JSON array of objects to insert. Supports {{variable}} interpolation.',
  139. showWhen: { field: 'operation', value: 'bulkInsert' }
  140. },
  141. batchSize: {
  142. type: 'number',
  143. title: 'Batch Size',
  144. description: 'Number of rows per INSERT statement (0 for all at once)',
  145. default: 100,
  146. showWhen: { field: 'operation', value: 'bulkInsert' }
  147. },
  148. customQuery: {
  149. type: 'string',
  150. title: 'Custom SQL',
  151. description: 'Custom SQL query with $1, $2, etc. placeholders for parameters. Supports {{variable}} interpolation.',
  152. showWhen: { field: 'operation', value: 'customSql' }
  153. },
  154. parameters: {
  155. type: 'array',
  156. title: 'Query Parameters',
  157. description: 'Parameters for $1, $2, etc. placeholders in custom SQL',
  158. items: {
  159. type: 'string'
  160. },
  161. showWhen: { field: 'operation', value: 'customSql' }
  162. }
  163. },
  164. required: ['credentialId', 'operation']
  165. };
  166. const inputSchema = {
  167. type: 'object',
  168. properties: {
  169. data: {
  170. type: 'any',
  171. description: 'Input data available for interpolation in queries'
  172. },
  173. parameters: {
  174. type: 'array',
  175. description: 'Override query parameters from input'
  176. },
  177. rows: {
  178. type: 'array',
  179. description: 'Array of objects for bulk insert (alternative to bulkData config)'
  180. }
  181. }
  182. };
  183. const outputSchema = {
  184. type: 'object',
  185. properties: {
  186. rows: { type: 'array', description: 'Query result rows as array of objects' },
  187. metadata: {
  188. type: 'object',
  189. description: 'Operation metadata',
  190. properties: {
  191. affectedRows: { type: 'number', description: 'Number of affected rows' },
  192. insertId: { type: 'number', description: 'Last auto-generated insert ID (if using RETURNING)' },
  193. rowCount: { type: 'number', description: 'Number of rows returned' }
  194. }
  195. },
  196. columns: { type: 'array', description: 'Column names from the result' },
  197. query: { type: 'string', description: 'The executed SQL query (for debugging)' }
  198. }
  199. };
  200. const outputs = [
  201. { name: 'main', displayName: 'Query Result', type: 'object', color: '#336791' }
  202. ];
  203. function parseJsonData(jsonString, data, fieldName) {
  204. if (!jsonString) {
  205. throw new Error(fieldName + ' is required');
  206. }
  207. let parsed = jsonString;
  208. if (typeof parsed === 'string') {
  209. parsed = smartbotic.utils.interpolate(parsed, data);
  210. try {
  211. parsed = JSON.parse(parsed);
  212. } catch (e) {
  213. throw new Error(fieldName + ' must be valid JSON: ' + e.message);
  214. }
  215. }
  216. return parsed;
  217. }
  218. function escapeIdentifier(name) {
  219. // PostgreSQL uses double quotes for identifiers
  220. return '"' + name.replace(/"/g, '""') + '"';
  221. }
  222. function buildWhereClause(conditions, data, paramOffset) {
  223. if (!conditions || conditions.length === 0) {
  224. return { clause: '', params: [], nextParam: paramOffset };
  225. }
  226. const clauses = [];
  227. const params = [];
  228. let paramIdx = paramOffset;
  229. for (const condition of conditions) {
  230. if (!condition.column) continue;
  231. const col = escapeIdentifier(condition.column);
  232. const op = condition.operator || '=';
  233. let value = condition.value;
  234. if (typeof value === 'string') {
  235. value = smartbotic.utils.interpolate(value, data);
  236. }
  237. if (op === 'IS NULL') {
  238. clauses.push(col + ' IS NULL');
  239. } else if (op === 'IS NOT NULL') {
  240. clauses.push(col + ' IS NOT NULL');
  241. } else if (op === 'IN' || op === 'NOT IN') {
  242. let values = value;
  243. if (typeof values === 'string') {
  244. try {
  245. values = JSON.parse(values);
  246. } catch (e) {
  247. values = values.split(',').map(v => v.trim());
  248. }
  249. }
  250. if (!Array.isArray(values)) {
  251. values = [values];
  252. }
  253. const placeholders = values.map(() => '$' + (paramIdx++)).join(', ');
  254. clauses.push(col + ' ' + op + ' (' + placeholders + ')');
  255. params.push(...values);
  256. } else {
  257. clauses.push(col + ' ' + op + ' $' + (paramIdx++));
  258. params.push(value);
  259. }
  260. }
  261. return {
  262. clause: clauses.length > 0 ? ' WHERE ' + clauses.join(' AND ') : '',
  263. params,
  264. nextParam: paramIdx
  265. };
  266. }
  267. function buildSelectQuery(config, data) {
  268. const table = config.table;
  269. if (!table) {
  270. throw new Error('Table is required for SELECT operations');
  271. }
  272. const columns = config.columns && config.columns.length > 0
  273. ? config.columns.map(escapeIdentifier).join(', ')
  274. : '*';
  275. let query = 'SELECT ' + columns + ' FROM ' + escapeIdentifier(table);
  276. const params = [];
  277. let paramIdx = 1;
  278. const where = buildWhereClause(config.whereConditions, data, paramIdx);
  279. query += where.clause;
  280. params.push(...where.params);
  281. paramIdx = where.nextParam;
  282. if (config.orderBy && config.orderBy.length > 0) {
  283. const orderClauses = config.orderBy
  284. .filter(o => o.column)
  285. .map(o => escapeIdentifier(o.column) + ' ' + (o.direction || 'ASC'));
  286. if (orderClauses.length > 0) {
  287. query += ' ORDER BY ' + orderClauses.join(', ');
  288. }
  289. }
  290. if (config.limit && config.limit > 0) {
  291. query += ' LIMIT $' + (paramIdx++);
  292. params.push(config.limit);
  293. if (config.offset && config.offset > 0) {
  294. query += ' OFFSET $' + (paramIdx++);
  295. params.push(config.offset);
  296. }
  297. }
  298. return { query, params };
  299. }
  300. function buildInsertQuery(config, data) {
  301. const table = config.table;
  302. if (!table) {
  303. throw new Error('Table is required for INSERT operations');
  304. }
  305. const insertData = parseJsonData(config.insertData, data, 'Insert data');
  306. if (!insertData || typeof insertData !== 'object' || Array.isArray(insertData)) {
  307. throw new Error('Insert data must be a JSON object');
  308. }
  309. const columns = Object.keys(insertData);
  310. if (columns.length === 0) {
  311. throw new Error('Insert data cannot be empty');
  312. }
  313. const values = Object.values(insertData);
  314. const placeholders = columns.map((_, i) => '$' + (i + 1)).join(', ');
  315. // Use RETURNING to get the inserted ID if available
  316. const query = 'INSERT INTO ' + escapeIdentifier(table) +
  317. ' (' + columns.map(escapeIdentifier).join(', ') + ')' +
  318. ' VALUES (' + placeholders + ')' +
  319. ' RETURNING *';
  320. return { query, params: values };
  321. }
  322. function buildUpdateQuery(config, data) {
  323. const table = config.table;
  324. if (!table) {
  325. throw new Error('Table is required for UPDATE operations');
  326. }
  327. const updateData = parseJsonData(config.updateData, data, 'Update data');
  328. if (!updateData || typeof updateData !== 'object' || Array.isArray(updateData)) {
  329. throw new Error('Update data must be a JSON object');
  330. }
  331. const columns = Object.keys(updateData);
  332. if (columns.length === 0) {
  333. throw new Error('Update data cannot be empty');
  334. }
  335. const values = Object.values(updateData);
  336. let paramIdx = 1;
  337. const setClause = columns.map(col => escapeIdentifier(col) + ' = $' + (paramIdx++)).join(', ');
  338. let query = 'UPDATE ' + escapeIdentifier(table) + ' SET ' + setClause;
  339. const params = [...values];
  340. const where = buildWhereClause(config.whereConditions, data, paramIdx);
  341. if (!where.clause) {
  342. throw new Error('WHERE conditions are required for UPDATE operations to prevent accidental data modification');
  343. }
  344. query += where.clause;
  345. params.push(...where.params);
  346. return { query, params };
  347. }
  348. function buildDeleteQuery(config, data) {
  349. const table = config.table;
  350. if (!table) {
  351. throw new Error('Table is required for DELETE operations');
  352. }
  353. let query = 'DELETE FROM ' + escapeIdentifier(table);
  354. const params = [];
  355. const where = buildWhereClause(config.whereConditions, data, 1);
  356. if (!where.clause) {
  357. throw new Error('WHERE conditions are required for DELETE operations to prevent accidental data loss');
  358. }
  359. query += where.clause;
  360. params.push(...where.params);
  361. return { query, params };
  362. }
  363. function buildUpsertQuery(config, data) {
  364. const table = config.table;
  365. if (!table) {
  366. throw new Error('Table is required for UPSERT operations');
  367. }
  368. const upsertData = parseJsonData(config.upsertData, data, 'Upsert data');
  369. if (!upsertData || typeof upsertData !== 'object' || Array.isArray(upsertData)) {
  370. throw new Error('Upsert data must be a JSON object');
  371. }
  372. const columns = Object.keys(upsertData);
  373. if (columns.length === 0) {
  374. throw new Error('Upsert data cannot be empty');
  375. }
  376. const values = Object.values(upsertData);
  377. const placeholders = columns.map((_, i) => '$' + (i + 1)).join(', ');
  378. const conflictKeys = config.conflictKey || [];
  379. if (conflictKeys.length === 0) {
  380. throw new Error('Conflict key columns are required for UPSERT operations in PostgreSQL');
  381. }
  382. let updateColumns = config.updateOnConflict || [];
  383. if (updateColumns.length === 0) {
  384. updateColumns = columns.filter(col => !conflictKeys.includes(col));
  385. }
  386. if (updateColumns.length === 0) {
  387. updateColumns = columns;
  388. }
  389. const conflictClause = conflictKeys.map(escapeIdentifier).join(', ');
  390. const updateClause = updateColumns
  391. .map(col => escapeIdentifier(col) + ' = EXCLUDED.' + escapeIdentifier(col))
  392. .join(', ');
  393. // PostgreSQL ON CONFLICT syntax
  394. const query = 'INSERT INTO ' + escapeIdentifier(table) +
  395. ' (' + columns.map(escapeIdentifier).join(', ') + ')' +
  396. ' VALUES (' + placeholders + ')' +
  397. ' ON CONFLICT (' + conflictClause + ')' +
  398. ' DO UPDATE SET ' + updateClause +
  399. ' RETURNING *';
  400. return { query, params: values };
  401. }
  402. function buildBulkInsertQueries(config, data, input) {
  403. const table = config.table;
  404. if (!table) {
  405. throw new Error('Table is required for BULK INSERT operations');
  406. }
  407. let bulkData = input.rows;
  408. if (!bulkData) {
  409. bulkData = parseJsonData(config.bulkData, data, 'Bulk data');
  410. }
  411. if (!Array.isArray(bulkData)) {
  412. throw new Error('Bulk data must be a JSON array of objects');
  413. }
  414. if (bulkData.length === 0) {
  415. throw new Error('Bulk data array cannot be empty');
  416. }
  417. const columns = Object.keys(bulkData[0]);
  418. if (columns.length === 0) {
  419. throw new Error('Bulk data objects cannot be empty');
  420. }
  421. const batchSize = config.batchSize > 0 ? config.batchSize : bulkData.length;
  422. const queries = [];
  423. for (let i = 0; i < bulkData.length; i += batchSize) {
  424. const batch = bulkData.slice(i, i + batchSize);
  425. const allValues = [];
  426. const rowPlaceholders = [];
  427. let paramIdx = 1;
  428. for (const row of batch) {
  429. const rowValues = columns.map(col => {
  430. const val = row[col];
  431. return val !== undefined ? val : null;
  432. });
  433. allValues.push(...rowValues);
  434. const rowParams = columns.map(() => '$' + (paramIdx++));
  435. rowPlaceholders.push('(' + rowParams.join(', ') + ')');
  436. }
  437. const query = 'INSERT INTO ' + escapeIdentifier(table) +
  438. ' (' + columns.map(escapeIdentifier).join(', ') + ')' +
  439. ' VALUES ' + rowPlaceholders.join(', ');
  440. queries.push({ query, params: allValues, rowCount: batch.length });
  441. }
  442. return queries;
  443. }
  444. function buildCustomQuery(config, data, input) {
  445. if (!config.customQuery) {
  446. throw new Error('Custom SQL query is required');
  447. }
  448. const query = smartbotic.utils.interpolate(config.customQuery, data);
  449. let params = input.parameters || config.parameters || [];
  450. if (params.length > 0) {
  451. params = params.map(p => {
  452. if (typeof p === 'string') {
  453. return smartbotic.utils.interpolate(p, data);
  454. }
  455. return p;
  456. });
  457. }
  458. return { query, params };
  459. }
  460. module.exports = {
  461. configSchema,
  462. inputSchema,
  463. outputSchema,
  464. outputs,
  465. async execute(config, input) {
  466. const credentialId = config.credentialId;
  467. const data = input.data || input;
  468. if (!credentialId) {
  469. throw new Error('PostgreSQL credential is required');
  470. }
  471. const operation = config.operation || 'select';
  472. let totalAffectedRows = 0;
  473. let lastInsertId = null;
  474. let executedQuery = '';
  475. if (operation === 'bulkInsert') {
  476. const queries = buildBulkInsertQueries(config, data, input);
  477. smartbotic.log.info('PostgreSQL BULK INSERT: ' + queries.length + ' batch(es)');
  478. for (const queryInfo of queries) {
  479. const options = {
  480. credentialId,
  481. database: config.database,
  482. query: queryInfo.query,
  483. params: queryInfo.params
  484. };
  485. const result = smartbotic.postgresql.query(options);
  486. if (!result.success) {
  487. smartbotic.log.error('PostgreSQL bulk insert failed: ' + result.error);
  488. throw new Error('PostgreSQL bulk insert failed: ' + result.error);
  489. }
  490. totalAffectedRows += result.affectedRows || 0;
  491. if (result.insertId) {
  492. lastInsertId = result.insertId;
  493. }
  494. executedQuery = queryInfo.query;
  495. }
  496. smartbotic.log.info('PostgreSQL BULK INSERT completed: ' + totalAffectedRows + ' rows inserted');
  497. return {
  498. rows: [],
  499. metadata: {
  500. affectedRows: totalAffectedRows,
  501. insertId: lastInsertId,
  502. rowCount: 0
  503. },
  504. columns: [],
  505. query: executedQuery
  506. };
  507. }
  508. let queryInfo;
  509. switch (operation) {
  510. case 'select':
  511. queryInfo = buildSelectQuery(config, data);
  512. break;
  513. case 'insert':
  514. queryInfo = buildInsertQuery(config, data);
  515. break;
  516. case 'update':
  517. queryInfo = buildUpdateQuery(config, data);
  518. break;
  519. case 'delete':
  520. queryInfo = buildDeleteQuery(config, data);
  521. break;
  522. case 'upsert':
  523. queryInfo = buildUpsertQuery(config, data);
  524. break;
  525. case 'customSql':
  526. queryInfo = buildCustomQuery(config, data, input);
  527. break;
  528. default:
  529. throw new Error('Invalid operation: ' + operation);
  530. }
  531. const { query, params } = queryInfo;
  532. smartbotic.log.info('PostgreSQL ' + operation.toUpperCase() + ': ' + query.substring(0, 100) + (query.length > 100 ? '...' : ''));
  533. const options = {
  534. credentialId,
  535. database: config.database,
  536. query,
  537. params
  538. };
  539. const result = smartbotic.postgresql.query(options);
  540. if (!result.success) {
  541. smartbotic.log.error('PostgreSQL query failed: ' + result.error);
  542. throw new Error('PostgreSQL query failed: ' + result.error);
  543. }
  544. smartbotic.log.info('PostgreSQL ' + operation.toUpperCase() + ' completed: ' +
  545. (result.affectedRows || 0) + ' affected, ' +
  546. (result.rows || []).length + ' rows returned');
  547. return {
  548. rows: result.rows || [],
  549. metadata: {
  550. affectedRows: result.affectedRows || 0,
  551. insertId: result.insertId || null,
  552. rowCount: (result.rows || []).length
  553. },
  554. columns: result.columns || [],
  555. query: query
  556. };
  557. }
  558. };