// Shared helpers for parsing JSON, reading form arrays, and duplicate-name checks. function parseJsonSafe(value) { if (!value) { return null; } if (typeof value === 'object') { return value; } try { return JSON.parse(value); } catch (_error) { return null; } } function readFormArray(body, key) { if (Array.isArray(body[key])) { return body[key]; } if (body[key] === undefined) { return []; } return [body[key]]; } function validateMaxLength(value, maxLength, fieldName) { const text = String(value || '').trim(); const limit = Number(maxLength); if (Number.isFinite(limit) && limit > 0 && text.length > limit) { const error = new Error(fieldName + ' must be ' + limit + ' characters or fewer.'); error.statusCode = 400; throw error; } return text; } function truncateToMaxLength(value, maxLength) { const text = String(value || '').trim(); const limit = Number(maxLength); if (!Number.isFinite(limit) || limit <= 0 || text.length <= limit) { return text; } return text.slice(0, limit); } function normalizePageNumber(value) { const pageNumber = Math.floor(Number(value) || 1); return Math.max(1, pageNumber); } function escapeLikeValue(value) { return String(value || '').replace(/[\\%_]/g, '\\$&'); } function normalizeSortDirection(value) { return String(value || '').trim().toLowerCase() === 'desc' ? 'desc' : 'asc'; } function buildSortOrderClause(sortColumns, sortKey, sortDirection) { const normalizedSortKey = String(sortKey || '').trim(); const normalizedSortDirection = normalizeSortDirection(sortDirection); const sortColumnMap = sortColumns && typeof sortColumns === 'object' ? sortColumns : {}; const sortExpression = normalizedSortKey ? sortColumnMap[normalizedSortKey] : null; if (!sortExpression) { return { clause: '', sortKey: normalizedSortKey, sortDirection: normalizedSortDirection }; } const expressions = Array.isArray(sortExpression) ? sortExpression : [sortExpression]; const orderBySql = expressions.map(function (expression) { return `${expression} ${normalizedSortDirection.toUpperCase()}`; }).join(', '); return { clause: ` ORDER BY ${orderBySql}`, sortKey: normalizedSortKey, sortDirection: normalizedSortDirection }; } function findTopLevelOrderByIndex(sql) { const text = String(sql || ''); let depth = 0; let inSingleQuote = false; let inDoubleQuote = false; let inBacktick = false; let lastOrderByIndex = -1; for (let index = 0; index < text.length; index += 1) { const character = text[index]; const previousCharacter = index > 0 ? text[index - 1] : ''; if (inSingleQuote) { if (character === '\'' && previousCharacter !== '\\') { inSingleQuote = false; } continue; } if (inDoubleQuote) { if (character === '"' && previousCharacter !== '\\') { inDoubleQuote = false; } continue; } if (inBacktick) { if (character === '`') { inBacktick = false; } continue; } if (character === '\'') { inSingleQuote = true; continue; } if (character === '"') { inDoubleQuote = true; continue; } if (character === '`') { inBacktick = true; continue; } if (character === '(') { depth += 1; continue; } if (character === ')' && depth > 0) { depth -= 1; continue; } if (depth === 0 && /[oO]/.test(character)) { const remaining = text.slice(index); if (/^order\s+by\b/i.test(remaining)) { lastOrderByIndex = index; } } } return lastOrderByIndex; } function findTopLevelWhereIndex(sql) { const text = String(sql || ''); let depth = 0; let inSingleQuote = false; let inDoubleQuote = false; let inBacktick = false; let lastWhereIndex = -1; for (let index = 0; index < text.length; index += 1) { const character = text[index]; const previousCharacter = index > 0 ? text[index - 1] : ''; if (inSingleQuote) { if (character === '\'' && previousCharacter !== '\\') { inSingleQuote = false; } continue; } if (inDoubleQuote) { if (character === '"' && previousCharacter !== '\\') { inDoubleQuote = false; } continue; } if (inBacktick) { if (character === '`') { inBacktick = false; } continue; } if (character === '\'') { inSingleQuote = true; continue; } if (character === '"') { inDoubleQuote = true; continue; } if (character === '`') { inBacktick = true; continue; } if (character === '(') { depth += 1; continue; } if (character === ')' && depth > 0) { depth -= 1; continue; } if (depth === 0 && /[wW]/.test(character)) { const remaining = text.slice(index); if (/^where\b/i.test(remaining)) { lastWhereIndex = index; } } } return lastWhereIndex; } function findTopLevelGroupByIndex(sql) { const text = String(sql || ''); let depth = 0; let inSingleQuote = false; let inDoubleQuote = false; let inBacktick = false; let lastGroupByIndex = -1; for (let index = 0; index < text.length; index += 1) { const character = text[index]; const previousCharacter = index > 0 ? text[index - 1] : ''; if (inSingleQuote) { if (character === '\'' && previousCharacter !== '\\') { inSingleQuote = false; } continue; } if (inDoubleQuote) { if (character === '"' && previousCharacter !== '\\') { inDoubleQuote = false; } continue; } if (inBacktick) { if (character === '`') { inBacktick = false; } continue; } if (character === '\'') { inSingleQuote = true; continue; } if (character === '"') { inDoubleQuote = true; continue; } if (character === '`') { inBacktick = true; continue; } if (character === '(') { depth += 1; continue; } if (character === ')' && depth > 0) { depth -= 1; continue; } if (depth === 0 && /[gG]/.test(character)) { const remaining = text.slice(index); if (/^group\s+by\b/i.test(remaining)) { lastGroupByIndex = index; } } } return lastGroupByIndex; } function buildSearchFilter(searchColumns, searchTerm) { const columns = Array.isArray(searchColumns) ? searchColumns.map(function (column) { return String(column || '').trim(); }).filter(Boolean) : []; const normalizedSearchTerm = String(searchTerm || '').trim().toLowerCase(); if (!columns.length || !normalizedSearchTerm) { return { clause: '', params: [] }; } const likeValue = `%${escapeLikeValue(normalizedSearchTerm)}%`; return { clause: ` WHERE (${columns.map(function (column) { return `LOWER(COALESCE(CAST(${column} AS CHAR), '')) LIKE ? ESCAPE '\\\\'`; }).join(' OR ')})`, params: columns.map(function () { return likeValue; }) }; } async function fetchPagedRows(pool, options) { const selectSql = String(options && options.selectSql || '').trim(); const countSql = String(options && options.countSql || '').trim(); const params = Array.isArray(options && options.params) ? options.params : []; const searchFilter = buildSearchFilter(options && options.searchColumns, options && options.searchTerm); const sortOrder = buildSortOrderClause(options && options.sortColumns, options && options.sortKey, options && options.sortDirection); const pageSize = Math.max(1, Number(options && options.pageSize) || 10); const currentPage = normalizePageNumber(options && options.page); if (!selectSql || !countSql) { throw new Error('fetchPagedRows requires selectSql and countSql.'); } const orderByIndex = findTopLevelOrderByIndex(selectSql); const groupByIndex = findTopLevelGroupByIndex(selectSql); let baseSelectSql = selectSql; let orderBySql = ''; let groupBySql = ''; if (orderByIndex >= 0) { baseSelectSql = selectSql.slice(0, orderByIndex).trim(); orderBySql = selectSql.slice(orderByIndex).trim(); } if (groupByIndex >= 0 && (orderByIndex < 0 || groupByIndex < orderByIndex)) { baseSelectSql = selectSql.slice(0, groupByIndex).trim(); groupBySql = selectSql.slice(groupByIndex, orderByIndex >= 0 ? orderByIndex : selectSql.length).trim(); } const hasTopLevelWhere = findTopLevelWhereIndex(baseSelectSql) >= 0; const searchClause = searchFilter.clause ? (hasTopLevelWhere ? searchFilter.clause.replace(/^\s*WHERE\s+/i, ' AND ') : searchFilter.clause) : ''; const filteredSelectSql = `${baseSelectSql}${searchClause}${groupBySql ? ' ' + groupBySql : ''}`; const countQuery = searchFilter.clause ? `SELECT COUNT(*) AS count FROM (${filteredSelectSql}) AS filtered_rows` : countSql; const countParams = searchFilter.clause ? params.concat(searchFilter.params) : params; const [countRows] = await pool.query(countQuery, countParams); const totalItems = Number(countRows && countRows[0] && countRows[0].count) || 0; const totalPages = Math.max(1, Math.ceil(totalItems / pageSize)); const safeCurrentPage = Math.min(currentPage, totalPages); const offset = (safeCurrentPage - 1) * pageSize; const activeOrderBySql = sortOrder.clause || (orderBySql ? ` ${orderBySql}` : ''); const selectQuery = `${filteredSelectSql}${activeOrderBySql} LIMIT ? OFFSET ?`; const [rows] = await pool.query(selectQuery, params.concat(searchFilter.params, [pageSize, offset])); return { rows: rows || [], totalItems: totalItems, totalPages: totalPages, currentPage: safeCurrentPage, pageSize: pageSize }; } async function fetchDuplicateName(pool, tableName, name, excludeId, columnName) { const normalizedName = String(name || '').trim(); if (!normalizedName) { return null; } const normalizedColumnName = String(columnName || 'name').trim() || 'name'; const params = [normalizedName]; let sql = `SELECT id, \`${normalizedColumnName}\` AS name FROM \`${tableName}\` WHERE LOWER(TRIM(\`${normalizedColumnName}\`)) = LOWER(TRIM(?))`; if (excludeId !== undefined && excludeId !== null) { sql += ' AND id <> ?'; params.push(excludeId); } sql += ' LIMIT 1'; const [rows] = await pool.query(sql, params); return rows[0] || null; } module.exports = { parseJsonSafe, readFormArray, validateMaxLength, truncateToMaxLength, normalizePageNumber, normalizeSortDirection, buildSortOrderClause, findTopLevelOrderByIndex, findTopLevelGroupByIndex, buildSearchFilter, fetchPagedRows, fetchDuplicateName };