diff --git a/lib/business/business-base.mjs b/lib/business/business-base.mjs index 208ca93..7bfe4de 100644 --- a/lib/business/business-base.mjs +++ b/lib/business/business-base.mjs @@ -1,6 +1,7 @@ import mssql from 'mssql'; import SqlHelper from './sql-helper.mjs'; import { sqlErrorMapper } from './error-mapper.mjs'; +import ConcatenatedColumns from './concatenated-columns.mjs'; const enums = { startDateTime: '00:00:00', @@ -256,8 +257,10 @@ class BusinessBase { return this.selectStatement || `SELECT ${alias}.* FROM ${tableName} ${alias}`; } - createRequest() { - return BusinessBase.businessObject.sql.createRequest(this.logger); + dbAdapter = 'sql'; + + getDatabaseAdapter() { + return BusinessBase.businessObject[this.dbAdapter]; } /** @@ -302,9 +305,8 @@ class BusinessBase { const where = await this.createWhere({ isStandard: this.standardTable, operationMode: OperationMode.load }); where[keyField] = id; - const request = this.createRequest(); - - const sql = BusinessBase.businessObject.sql; + const sql = this.getDatabaseAdapter(); + const request = sql.createRequest(); query = sql.addParameters({ query, request, parameters: where, forWhere: true }); @@ -402,7 +404,7 @@ class BusinessBase { } const tableName = this.getTableName(); const isUpdate = id ? parseInt(id) !== 0 : false; - const sql = BusinessBase.businessObject.sql; + const sql = this.getDatabaseAdapter(); const clientId = user.scopeId; // todo: Client check @@ -642,14 +644,24 @@ class BusinessBase { return await sql.insertUpdate({ tableName: this.getTableName(), keyField, id, json: values, update: true, logger: this.logger }); } + /** + * queryFileName - Optional property to specify a SQL file for the list statement. If provided, this file will be used instead of the default listStatement or generated SELECT statement. + */ + queryFileName = null; + async getListStatement(listParameters) { - const listStatement = this.listStatement || (this.standardTable && this.useView !== false ? `SELECT * FROM vw${this.getTableName()}List Main` : this.getSelectStatement()); + let listStatement; + if (this.queryFileName) { + listStatement = await listParameters.sql.getQuery(this.queryFileName); + } else { + listStatement = this.listStatement || (this.standardTable && this.useView !== false ? `SELECT * FROM vw${this.getTableName()}List Main` : this.getSelectStatement()); + } const isStandard = this.standardTable === true && listStatement.indexOf("vw") === -1; return { listStatement, isStandard }; } normalizeListStatement(result) { - if(typeof result === 'string') { + if (typeof result === 'string') { return { listStatement: result, isStandard: this.standardTable === true && result.indexOf("vw") === -1 @@ -659,14 +671,14 @@ class BusinessBase { } async lookupList({ scopeId }) { - const request = this.createRequest(); + const sql = this.getDatabaseAdapter(); + const request = sql.createRequest(); const { keyField, lookupSortOrder, defaultSortOrder, displayField, clientBased, lookupListStatement = '', tableName } = this; const sort = lookupSortOrder || defaultSortOrder; if (lookupListStatement) { const result = await request.query(lookupListStatement); return result.recordset; } - const sql = BusinessBase.businessObject.sql; let { listStatement, isStandard } = this.normalizeListStatement( await this.getListStatement({ @@ -724,9 +736,9 @@ class BusinessBase { */ async list({ start = 0, limit = 100, sort, filter, groupBy, include, exclude, returnCount = true, ...options }) { sort = sort || this.defaultSortOrder; - const request = this.createRequest(); - const { keyField } = this; - const sql = BusinessBase.businessObject.sql; + const sql = this.getDatabaseAdapter(); + const request = sql.createRequest(); + const { keyField, concatenatedColumns = [] } = this; const whereArr = this.parseJson(filter, []); let totalStatement = "SELECT COUNT(1) AS TotalCount"; @@ -746,6 +758,7 @@ class BusinessBase { exclude, returnCount, relations, + concatenatedColumns, operationMode: OperationMode.list }; @@ -783,7 +796,8 @@ class BusinessBase { } // Hook: addAdditionalColumns - Allow adding custom JOINs and columns - if (typeof this.addAdditionalColumns === 'function' ) { + if (typeof this.addAdditionalColumns === 'function') { + hookParameters.listStatement = listStatement; const result = await this.addAdditionalColumns(hookParameters); if (result) { @@ -819,8 +833,39 @@ class BusinessBase { } if (whereArr.length) { whereArr.forEach((ele) => { + if (!ele) return; // guard against null/ undefined const { operator, field, value, type } = ele; + if (!field) return; // guard against filter objects with missing field name const filterValue = compareLookups[operator]({ v: value, field, type }); + const concatIdx = concatenatedColumns.findIndex(c => c.DisplayColumn === field) ?? -1; + if (concatIdx !== -1) { + const concatenatedColumn = concatenatedColumns[concatIdx]; + const paramName = `concat_${index}`; + if (typeof filterValue === 'string') { + where[`filter_${index}`] = { + statement: ConcatenatedColumns.applyStringFilter({ + sql, + value, + concatenatedColumn, + request, + paramName, + operator: sql.normalizeOperator(operator) + }) + }; + return; + } else if (filterValue) { + where[`filter_${index}`] = { + statement: ConcatenatedColumns.applyStringFilter({ + value: filterValue.value, + concatenatedColumn, + request, + paramName, + operator: filterValue.operator + }) + }; + } + return; + } let fieldName = isDataFromView ? field : `Main.${field}`; if (filterFields[field]) { fieldName = `${filterFields[field]}.${field}`; @@ -860,12 +905,11 @@ class BusinessBase { } if (limit > 0) { - query += ' OFFSET @_start ROWS FETCH NEXT @_limit ROWS ONLY'; - query = BusinessBase.businessObject.sql.addParameters({ query: query, request, parameters: { _start: start, _limit: limit }, forWhere: false }); + query = sql.addPaging({ query, request, start, limit }); } if (groupBy) { - let groupByFields = groupBy.split(','); + let groupByFields = Array.isArray(groupBy) ? groupBy : groupBy.split(','); groupByFields = groupByFields.map(field => SqlHelper.sanitizeField(field)); const groupByStatement = ' GROUP BY ' + SqlHelper.sanitizeField(groupByFields.join(', ')); query += groupByStatement; @@ -879,17 +923,26 @@ class BusinessBase { } - const result = await request.query(query); + const result = await sql.runQuery({ request, type: "query", query }); + if (result.err) throw result.err; const listResult = { - records: result.recordset + records: result.recordsets[0] }; + if (listResult.records?.length && concatenatedColumns.length) { + const resultValue = await ConcatenatedColumns.addColumns({ records: listResult.records, sql, columns: concatenatedColumns }); + if (resultValue !== null && resultValue !== undefined) { + listResult.records = resultValue.recordset; + } + } + + if (returnCount) { if (limit > 0) { listResult.recordCount = result.recordsets[1][0].TotalCount; } else { - listResult.recordCount = result.rowsAffected[0]; + listResult.recordCount = result.rowsAffected ? result.rowsAffected[0] : 0; } } diff --git a/lib/business/concatenated-columns.mjs b/lib/business/concatenated-columns.mjs new file mode 100644 index 0000000..657a1bb --- /dev/null +++ b/lib/business/concatenated-columns.mjs @@ -0,0 +1,105 @@ +class ConcatenatedColumns { + static async addColumns({ records, sql, maxVariables = 1500, columns }) { + if (!records || !records.length || !columns || columns.length === 0) { + return; + } + const keys = {}; + for (const colConfig of columns) { + const parentKeyColumn = colConfig.ParentColumn; + const joinColumn = colConfig.JoinColumn || parentKeyColumn; + const colName = colConfig.ColumnName; + const displayColumn = colConfig.DisplayColumn; + if (!keys[parentKeyColumn]) { + keys[parentKeyColumn] = records.reduce((ids, dr) => { + if (dr[parentKeyColumn] !== null) { + ids.push(dr[parentKeyColumn]); + } + return ids; + }, []); + } + const ids = keys[parentKeyColumn]; + if (records && records.length && !records[0].hasOwnProperty(colName)) { + records.forEach(dr => dr[colName] = null); + } + if (ids.length > 0) { + let results = []; + if (colConfig.listMethod) { + results = await colConfig.listMethod(ids); + } else { + let startIndex = 0; + while (startIndex < ids.length) { + let idsForQuery; + + if (startIndex === 0 && ids.length < maxVariables) { + idsForQuery = ids; + startIndex = ids.length; + } else { + let count = ids.length - startIndex; + + if (count > maxVariables) { + count = maxVariables; + } + const idArray = ids.slice(startIndex, startIndex + count); + idsForQuery = idArray; + startIndex += count; + } + let query = colConfig.Query; + const where = { [joinColumn]: { value: idsForQuery, operator: 'in' } }; + const request = sql.createRequest(); + query = sql.addParameters({ request, query, parameters: where, forWhere: true }); + const { data: tempDt } = await sql.execute({ query, request }); + results = results.concat(tempDt); + } + } + const infoParser = colConfig.infoParser || ConcatenatedColumns.defaultInfoParser; + const resultsByParentId = results.reduce((lookup, row) => { + const parentId = row[joinColumn]; + if (!lookup.has(parentId)) { + lookup.set(parentId, []); + } + lookup.get(parentId).push(row); + return lookup; + }, new Map()); + records.forEach(dr => { + if (dr[parentKeyColumn] !== null) { + const parentId = dr[parentKeyColumn]; + const childRows = resultsByParentId.get(parentId) || []; + const rowInfo = childRows.reduce((infot, childRow) => { + const infoToAppend = infoParser({ row: childRow, displayColumn }); + if (infoToAppend !== null && infoToAppend?.length > 0) { + infot.push(infoToAppend); + } + return infot; + }, []) + dr[colName] = rowInfo.join(', '); + } + }); + } + } + return records; + } + + static defaultInfoParser({ row, displayColumn }) { + return row[displayColumn] !== null ? row[displayColumn] : null; + } + + static applyStringFilter({ sql, value, concatenatedColumn, request, paramName = null, operator = 'LIKE' }) { + let childQuery = concatenatedColumn.Query; + const fieldName = concatenatedColumn.FilterColumn || concatenatedColumn.DisplayColumn; + const parentColumn = concatenatedColumn.ParentColumn; + const joinColumn = concatenatedColumn.JoinColumn || parentColumn; + // Use caller-supplied paramName (unique per filter index) to avoid collisions; + // fall back to fieldName only when called outside the standard filter pipeline. + const uniqueParam = paramName || fieldName; + childQuery = sql.addParameters({ + query: childQuery, + request, + parameters: { [uniqueParam]: { fieldName, operator, value } }, + forWhere: true, + }); + const subQuery = `SELECT ${joinColumn} FROM (${childQuery}) ConcatenatedSubQuery`; + return `${parentColumn} IN (${subQuery})`; + } +} + +export default ConcatenatedColumns; \ No newline at end of file diff --git a/lib/mysql.js b/lib/mysql.js index 89487a0..bc39685 100644 --- a/lib/mysql.js +++ b/lib/mysql.js @@ -83,8 +83,14 @@ class Mysql extends Sql { try { const startTime = Date.now(); const result = await request[type](query, request.params); + let recordsets = result[0]; this.logSlowQuery({ startTime, query, type, request }); - return { success: true, data: result[0], ...result }; + if (Array.isArray(recordsets) && recordsets.length > 0 && Array.isArray(recordsets[0])) { + // nothing + } else { + recordsets = [recordsets]; + } + return { success: true, data: recordsets[0], recordset: recordsets[0], recordsets, ...result }; } catch (err) { const loggerToUse = request._logger || this.logger; loggerToUse.error({ err, query, parameters: request.params, type }); @@ -94,10 +100,10 @@ class Mysql extends Sql { createRequest(logger) { const loggerToUse = logger || this.logger; - const queryLogger = createQueryLogger({ - queryLogThreshold: this.queryLogThreshold, - timeoutLogLevel: this.timeoutLogLevel, - logger: loggerToUse + const queryLogger = createQueryLogger({ + queryLogThreshold: this.queryLogThreshold, + timeoutLogLevel: this.timeoutLogLevel, + logger: loggerToUse }); const pool = this.pool; const request = { @@ -140,6 +146,11 @@ class Mysql extends Sql { if (value === undefined) value = null; this.params[name] = value; } + + addPaging({ query, request, start, limit }) { + query += ' LIMIT :z_start, :z_limit'; + return this.addParameters({ query, request, parameters: { z_start: start, z_limit: limit }, forWhere: false }); + } } export default Mysql; \ No newline at end of file diff --git a/lib/sql.js b/lib/sql.js index 9f9f057..3db39ae 100644 --- a/lib/sql.js +++ b/lib/sql.js @@ -25,6 +25,19 @@ const createQueryLogger = function ({ queryLogThreshold, timeoutLogLevel, logger }; } + +// Map custom operators to SQL standard +const normalizeOperatorMap = { + 'is': 'IS NULL', + 'is not': 'IS NOT NULL', + 'is null': 'IS NULL', + 'isnull': 'IS NULL', + 'is not null': 'IS NOT NULL', + 'isnotnull': 'IS NOT NULL', + 'datetime': 'BETWEEN', // Convert DATETIME to BETWEEN for date ranges +}; + + class Sql { logger = logger; @@ -38,6 +51,23 @@ class Sql { numeric: [mssql.TinyInt, mssql.SmallInt, mssql.Int, mssql.BigInt, mssql.Decimal, mssql.Float, mssql.Money, mssql.SmallMoney] }; + /** + * Normalizes filter operators to SQL standard format. + * Maps custom/frontend operators to standard SQL operators that DFramework understands. + * Case-insensitive matching to handle variations in operator naming. + * + * @param {string} operator - The operator to normalize + * @returns {string} - Normalized SQL operator + */ + normalizeOperator(operator) { + if (!operator) return '='; + + // Normalize to lowercase and trim for consistent mapping + const normalized = operator.toLowerCase().trim(); + + return normalizeOperatorMap[normalized] || operator; + } + parameterPrefix = "@"; forceCaseInsensitive = false; /** @@ -1167,6 +1197,11 @@ class Sql { } } } + + addPaging({ query, request, start, limit }) { + query += ' OFFSET @z_start ROWS FETCH NEXT @z_limit ROWS ONLY'; + return this.addParameters({ query: query, request, parameters: { z_start: start, z_limit: limit }, forWhere: false }); + } } export default Sql;