/** * @NApiVersion 2.1 * @NScriptType Suitelet * @NModuleScope Public * @Authors Mosses * @Version 2025.6_Moss_Stable * @Changelog * - 2025.6-Moss_Stable-DML-Safety: Client side regex now detects DROP/TRUNCATE. Server side parser now explicitly blocks DROP/TRUNCATE with a helpful error message. * - 2025.5-Moss_Stable-Create-Parser: Added client side detection for `CREATE`. Added server side `executeCreate` function to parse `CREATE.. SET...` syntax and use `record.create`. */ var file, https, log, query, record, render, runtime, scriptURL, url; var datatablesEnabled = true, remoteLibraryEnabled = true, rowsReturnedDefault = 50, queryFolderID = null, workbooksEnabled = false; const Modules = ['N/file', 'N/https', 'N/log', 'N/query', 'N/record', 'N/render', 'N/runtime', 'N/ui/serverWidget', 'N/url']; define(Modules, main); function main(fileModule, httpsModule, logModule, queryModule, recordModule, renderModule, runtimeModule, serverWidgetModule, urlModule) { file = fileModule; https = httpsModule; log = logModule; query = queryModule; record = recordModule; render = renderModule; runtime = runtimeModule; serverWidget = serverWidgetModule; url = urlModule; return { onRequest } } function onRequest(context) { const scriptId = runtime.getCurrentScript().id; const scriptDeployId = runtime.getCurrentScript().deploymentId; scriptURL = url.resolveScript({ scriptId: scriptId, deploymentId: scriptDeployId, returnExternalURL: false }); if (context.request.method == 'POST') handlePostRequest(context); else handleGetRequest(context); } function handleGetRequest(context) { if (context.request.parameters.hasOwnProperty('function')) { if (context.request.parameters['function'] == 'tablesReference') generateTablesReferencePageHtml(context); if (context.request.parameters['function'] == 'documentGenerate') generateDocumentFromQueryResults(context); } else { var form = serverWidget.createForm({ title: `SuiteQL Data Manipulation Tool`, hideNavBar: true }); var htmlField = form.addField({ id: 'custpage_field_html', type: 'inlineHTML', label: 'HTML' }); htmlField.defaultValue = generateMainToolInterfaceHtml(); context.response.writePage(form); } } function handlePostRequest(context) { context.response.setHeader('Content-Type', 'application/json'); var requestPayload; try { requestPayload = JSON.parse(context.request.body); } catch (e) { log.error("Error in Handle Post Request:", e); context.response.write(e); return; } switch (requestPayload['function']) { case 'documentSubmit': return storeDocumentInfoInSessionForGeneration(context, requestPayload); case 'queryExecute': return executeQuery(context, requestPayload); case 'sqlFileExists': return checkIfSqlFileExistsInLocalLibrary(context, requestPayload); case 'sqlFileLoad': return loadSqlFileFromLocalLibrary(context, requestPayload); case 'sqlFileSave': return saveSqlFileToLocalLibrary(context, requestPayload); case 'localLibraryFilesGet': return getLocalQueryLibraryFiles(context); case 'workbookLoad': return loadQueryFromWorkbook(context, requestPayload); case 'workbooksGet': return getListOfAvailableWorkbooks(context); default: log.error({ title: 'Payload - Unsupported Function', details: requestPayload['function'] }); } } function generateDocumentFromQueryResults(context) { try { var sessionScope = runtime.getCurrentSession(); var docInfo = JSON.parse(sessionScope.get({ name: 'suiteQLDocumentInfo' })); var moreRecords = true; var pagatedRowBegin = docInfo.rowBegin; var paginatedRowEnd = docInfo.rowEnd; var queryParams = new Array(); var records = new Array(); do { var paginatedSQL = 'SELECT * FROM ( SELECT ROWNUM AS ROWNUMBER, * FROM (' + docInfo.query + ' ) ) WHERE ( ROWNUMBER BETWEEN ' + paginatedRowBegin + ' AND ' + paginatedRowEnd + ')'; var queryResults = query.runSuiteQL({ query: paginatedSQL, params: queryParams }).asMappedResults(); records = records.concat(queryResults); if (queryResults.length < 5000) { moreRecords = false; } paginatedRowBegin = paginatedRowBegin + 5000; } while (moreRecords); var recordsDataSource = { 'records': records }; var renderer = render.create(); renderer.addCustomDataSource({ alias: 'results', format: render.DataSource.OBJECT, data: recordsDataSource }); renderer.templateContent = docInfo.template; if (docInfo.docType == 'pdf') { let renderObj = renderer.renderAsPdf(); let pdfString = renderObj.getContents(); context.response.setHeader('Content-Type', 'application/pdf'); context.response.write(pdfString); } else { let htmlString = renderer.renderAsString(); context.response.setHeader('Content-Type', 'text/html'); context.response.write(htmlString); } } catch (e) { log.error({ title: 'generateDocumentFromQueryResults Error', details: e }); context.response.write('Error: ' + e); } } function storeDocumentInfoInSessionForGeneration(context, requestPayload) { try { var responsePayload; var sessionScope = runtime.getCurrentSession(); sessionScope.set({ name: 'suiteQLDocumentInfo', value: JSON.stringify(requestPayload) }); responsePayload = { 'submitted': true } } catch (e) { log.error({ title: 'StoreDocumentInfoInSessionForGeneration Error:', details: e }); responsePayload = { 'error': e } } context.response.write(JSON.stringify(responsePayload, null, 5)); } function executeDelete(sql) { const deleteRegex = /^DELETE\s+FROM\s+([a-zA-Z0-9_]+)\s+WHERE\s+id\s*=\s*(\d+)/i; const match = sql.match(deleteRegex); if (!match) { throw new Error('Unsupported DELETE format. Use: DELETE FROM [recordtype] WHERE id = [id]'); } const [, recordType, recordId] = match; const id = record.delete({ type: recordType, id: Number(recordId), }); return { success: true, message: `Record deleted successfully. Type: ${recordType}, ID: ${id}` }; } function executeUpdate(sql) { const updateRegex = /^UPDATE\s+([a-zA-Z0-9_]+)\s+SET\s+(.+)\s+WHERE\s+id\s*=\s*(\d+)/i; const match = sql.match(updateRegex); if (!match) { throw new Error('Unsupported UPDATE format. Use: UPDATE [recordtype] SET [field] = \'[value]\' WHERE id = [id]'); } const [, recordType, setClause, recordId] = match; const values = {}; // Basic parser for SET clause: key = 'value', key2 = 'value2' // This is limited and expects strings to be in single quotes. const pairRegex = /([a-zA-Z0-9_]+)\s*=\s*(?:'([^']*)'|(\d+(?:\.\d+)?))/g; let pairMatch; while ((pairMatch = pairRegex.exec(setClause)) !== null) { const key = pairMatch[1]; const stringValue = pairMatch[2]; const numberValue = pairMatch[3]; values[key] = (stringValue !== undefined) ? stringValue : Number(numberValue); } if (Object.keys(values).length === 0) { throw new Error('Could not parse SET clause. Ensure values are in single quotes (e.g., field = \'value\') or are numbers.'); } const id = record.submitFields({ type: recordType, id: Number(recordId), values: values, options: { enableSourcing: false, ignoreMandatoryFields: true } }); return { success: true, message: `Record updated successfully. Type: ${recordType}, ID: ${id}` }; } function executeInsert(sql) { const insertRegex = /^INSERT\s+INTO\s+([a-zA-Z0-9_]+)\s+\(([^)]+)\)\s+VALUES\s+\(([^)]+)\)/i; const match = sql.match(insertRegex); if (!match) { throw new Error('Unsupported INSERT format. Use: INSERT INTO [recordtype] (field1, field2) VALUES (\'value1\', \'value2\')'); } const [, recordType, fieldsClause, valuesClause] = match; const fields = fieldsClause.split(',').map(f => f.trim()); const values = valuesClause.split(',').map(v => { let val = v.trim(); if (val.startsWith("'") && val.endsWith("'")) { return val.substring(1, val.length - 1); } if (!isNaN(val)) { return Number(val); } return val; }); if (fields.length !== values.length) { throw new Error('Field count does not match value count.'); } let newRecord = record.create({ type: recordType, isDynamic: true }); for (let i = 0; i < fields.length; i++) { newRecord.setValue({ fieldId: fields[i], value: values[i] }); } const newId = newRecord.save({ enableSourcing: false, ignoreMandatoryFields: true }); return { success: true, message: `Record created successfully. Type: ${recordType}, New ID: ${newId}` }; } function executeCreate(sql) { const createRegex = /^CREATE\s+([a-zA-Z0-9_]+)\s+SET\s+(.+)/i; const match = sql.match(createRegex); if (!match) { throw new Error('Unsupported CREATE format. Use: CREATE [recordtype] SET [field] = \'[value]\''); } const [, recordType, setClause] = match; const values = {}; const pairRegex = /([a-zA-Z0-9_]+)\s*=\s*(?:'([^']*)'|(\d+(?:\.\d+)?))/g; let pairMatch; while ((pairMatch = pairRegex.exec(setClause)) !== null) { const key = pairMatch[1]; const stringValue = pairMatch[2]; const numberValue = pairMatch[3]; values[key] = (stringValue !== undefined) ? stringValue : Number(numberValue); } if (Object.keys(values).length === 0) { throw new Error('Could not parse SET clause. Ensure values are in single quotes (e.g., field = \'value\') or are numbers.'); } let newRecord = record.create({ type: recordType, isDynamic: true }); for (const fieldId in values) { if (Object.hasOwnProperty.call(values, fieldId)) { newRecord.setValue({ fieldId: fieldId, value: values[fieldId] }); } } const newId = newRecord.save({ enableSourcing: false, ignoreMandatoryFields: true }); return { success: true, message: `Record created successfully. Type: ${recordType}, New ID: ${newId}` }; } function parseAndExecuteDml(sql) { const trimmedSql = sql.trim(); let beginTime = new Date().getTime(); let result; try { if (/^DELETE/i.test(trimmedSql)) { result = executeDelete(trimmedSql); } else if (/^UPDATE/i.test(trimmedSql)) { result = executeUpdate(trimmedSql); } else if (/^INSERT/i.test(trimmedSql)) { result = executeInsert(trimmedSql); } else if (/^CREATE/i.test(trimmedSql)) { result = executeCreate(trimmedSql); } else if (/^DROP/i.test(trimmedSql) || /^TRUNCATE/i.test(trimmedSql)) { throw new Error('Unsupported DML operation. `DROP` and `TRUNCATE` are blocked for safety. To delete a single record, use: DELETE FROM [recordtype] WHERE id = [id]'); } else { throw new Error('Unsupported DML operation. Only simple CREATE, INSERT, UPDATE, and DELETE are supported.'); } let elapsedTime = (new Date().getTime() - beginTime); return { dml: true, success: true, message: result.message, elapsedTime: elapsedTime }; } catch (e) { let elapsedTime = (new Date().getTime() - beginTime); log.error({ title: 'DML Parser Error', details: e }); return { dml: true, success: false, error: { message: e.message }, elapsedTime: elapsedTime }; } } function executeQuery(context, requestPayload) { try { var responsePayload; var queryParams = new Array(); var nestedSQL = requestPayload.query + "\n"; // DML Execution Path if (requestPayload.isDml) { responsePayload = parseAndExecuteDml(nestedSQL); } else { var moreRecords = true; var records = new Array(); var totalRecordCount = 0; var paginatedRowBegin = requestPayload.rowBegin; var paginatedRowEnd = requestPayload.rowEnd; if ((requestPayload.viewsEnabled) && (queryFolderID !== null)) { var pattern = /(?:^|\s)\#(\w+)\b/ig; var views = nestedSQL.match(pattern); if ((views !== null) && (views.length > 0)) { for (let i = 0; i < views.length; i++) { view = views[i].replace(/\s+/g, ''); viewFileName = view.substring(1, view.length) + '.sql'; var sql = 'SELECT ID FROM File WHERE ( Folder = ? ) AND ( Name = ? )'; var queryResults = query.runSuiteQL({ query: sql, params: [queryFolderID, viewFileName] }); var files = queryResults.asMappedResults(); if (files.length == 1) { var fileObj = file.load({ id: files[0].id }); nestedSQL = nestedSQL.replace(view, '( ' + fileObj.getContents() + ' ) AS ' + view.substring(1, view.length)); } else { throw { 'name:': 'UnresolvedViewException', 'message': 'Unresolved View ' + viewFileName } } } } } let beginTime = new Date().getTime(); if (requestPayload.paginationEnabled) { do { var paginatedSQL = 'SELECT * FROM ( SELECT ROWNUM AS ROWNUMBER, * FROM ( ' + nestedSQL + ' ) ) WHERE ( ROWNUMBER BETWEEN ' + paginatedRowBegin + ' AND ' + paginatedRowEnd + ')'; var queryResults = query.runSuiteQL({ query: paginatedSQL, params: queryParams }).asMappedResults(); records = records.concat(queryResults); if (queryResults.length < 5000) { moreRecords = false; } paginatedRowBegin = paginatedRowBegin + 5000; } while (moreRecords); } else { records = query.runSuiteQL({ query: nestedSQL, params: queryParams }).asMappedResults(); } let elapsedTime = (new Date().getTime() - beginTime); responsePayload = { 'records': records, 'elapsedTime': elapsedTime } if (requestPayload.returnTotals) { if (records.length > 0) { var paginatedSQL = 'SELECT COUNT(*) AS TotalRecordCount FROM ( ' + nestedSQL + ' )'; var queryResults = query.runSuiteQL({ query: paginatedSQL, params: queryParams }).asMappedResults(); responsePayload.totalRecordCount = queryResults[0].totalrecordcount; } } } } catch (e) { log.error({ title: 'executeQuery Error', details: e }); responsePayload = { 'error': e } } context.response.write(JSON.stringify(responsePayload, null, 5)); } function getLocalQueryLibraryFiles(context) { var responsePayload; var sql = ` SELECT ID, Name, Description FROM File WHERE ( Folder = ? ) ORDER BY Name `; var queryResults = query.runSuiteQL({ query: sql, params: [queryFolderID] }); var records = queryResults.asMappedResults(); if (records.length > 0) { responsePayload = { 'records': records }; } else { responsePayload = { 'error': 'No SQL Files' }; } context.response.write(JSON.stringify(responsePayload, null, 5)); } function checkIfSqlFileExistsInLocalLibrary(context, requestPayload) { var responsePayload; var sql = ` SELECT ID FROM File WHERE ( Folder = ? ) AND ( Name = ? ) `; var queryResults = query.runSuiteQL({ query: sql, params: [queryFolderID, requestPayload.filename] }); var records = queryResults.asMappedResults(); if (records.length > 0) { responsePayload = { 'exists': true }; } else { responsePayload = { 'exists': false }; } context.response.write(JSON.stringify(responsePayload, null, 5)); } function loadSqlFileFromLocalLibrary(context, requestPayload) { var responsePayload; try { var fileObj = file.load({ id: requestPayload.fileID }); responsePayload = {} responsePayload.file = fileObj; responsePayload.sql = fileObj.getContents(); } catch (e) { log.error({ title: 'loadSqlFileFromLocalLibrary Error', details: e }); responsePayload = { 'error': e } } context.response.write(JSON.stringify(responsePayload, null, 5)); } function saveSqlFileToLocalLibrary(context, requestPayload) { var responsePayload; try { var fileObj = file.create({ name: requestPayload.filename, contents: requestPayload.contents, description: requestPayload.description, fileType: file.Type.PLAINTEXT, folder: queryFolderID, isOnline: false }); var fileID = fileObj.save(); responsePayload = {} responsePayload.fileID = fileID; } catch (e) { log.error({ title: 'saveSqlFileToLocalLibrary Error', details: e }); responsePayload = { 'error': e } } context.response.write(JSON.stringify(responsePayload, null, 5)); } function loadQueryFromWorkbook(context, requestPayload) { var responsePayload; try { var loadedQuery = query.load({ id: requestPayload.scriptID }); responsePayload = {} responsePayload.sql = loadedQuery.toSuiteQL().query; } catch (e) { log.error({ title: 'loadQueryFromWorkbook Error', details: e }); responsePayload = { 'error': e } } context.response.write(JSON.stringify(responsePayload, null, 5)); } function getListOfAvailableWorkbooks(context) { var responsePayload; var sql = ` SELECT ScriptID, Name, Description, BUILTIN.DF( Owner ) AS Owner FROM UsrSavedSearch ORDER BY Name `; var queryResults = query.runSuiteQL({ query: sql, params: [] }); var records = queryResults.asMappedResults(); if (records.length > 0) { responsePayload = { 'records': records }; } else { responsePayload = { 'error': 'No Workbooks' }; } context.response.write(JSON.stringify(responsePayload, null, 5)); } function generateMainToolInterfaceHtml() { return ` SuiteQL Data Manipulation Tool
menu settings
Result Format
NULL Display
${generateDmlWarningModalHtml()} ${generateLocalLibraryLoadModalHtml()} ${generateRemoteLibraryLoadModalHtml()} ${generateRecentQueriesModalHtml()} ${generateSaveQueryModalHtml()} ${generateWorkbooksModalHtml()} `; } function generateTablesReferencePageHtml(context) { var form = serverWidget.createForm({ title: 'SuiteQL Tables Reference', hideNavBar: false }); var htmlField = form.addField({ id: 'custpage_field_html', type: serverWidget.FieldType.INLINEHTML, label: 'HTML' }); htmlField.defaultValue = `
Tables
Loading...
Details
Select a table to view its details.
`; context.response.writePage(form); } function generateDmlWarningModalHtml() { return ``; } function generateLocalLibraryLoadModalHtml() { return ``; } function generateRemoteLibraryLoadModalHtml() { return ``; } function generateRecentQueriesModalHtml() { return ``; } function generateSaveQueryModalHtml() { return `