UNPKG

@trithanka/sql-builder

Version:

A lightweight, function-based, chainable SQL query builder for Node.js using MySQL pool connections.

189 lines (162 loc) 6.36 kB
function createSelectBuilder(baseSql) { // Input validation if (!baseSql || typeof baseSql !== 'string' || baseSql.trim() === '') { throw new Error('Base SQL must be a non-empty string'); } let sql = baseSql.trim(); const whereClauses = []; const values = []; let groupByClause = ''; const havingClauses = []; let orderByClause = ''; let limitClause = ''; let offsetClause = ''; // Helper function to safely validate and sanitize column names function sanitizeColumnName(column) { if (!column || typeof column !== 'string') { throw new Error('Column name must be a non-empty string'); } // Only allow alphanumeric characters, underscores, and dots for table.column format if (!/^[a-zA-Z_][a-zA-Z0-9_.]*$/.test(column)) { throw new Error(`Invalid column name: ${column}. Only alphanumeric characters, underscores, and dots are allowed.`); } return column; } // Helper function to validate direction function validateDirection(direction) { const validDirections = ['ASC', 'DESC']; const upperDirection = direction.toUpperCase(); if (!validDirections.includes(upperDirection)) { throw new Error(`Invalid ORDER BY direction: ${direction}. Must be 'ASC' or 'DESC'.`); } return upperDirection; } // Helper function to detect WHERE clause more accurately function hasExistingWhereClause(sql) { // Remove comments first let cleanSql = sql.replace(/--.*$/gm, ''); // Remove single-line comments cleanSql = cleanSql.replace(/\/\*[\s\S]*?\*\//g, ''); // Remove multi-line comments // Remove string literals cleanSql = cleanSql.replace(/'[^']*'/g, ''); // Remove single-quoted strings cleanSql = cleanSql.replace(/"([^"]|"")*"/g, ''); // Remove double-quoted strings // Now check for WHERE keyword return /\bWHERE\b/i.test(cleanSql); } // Helper function to validate value function isValidValue(value) { // Handle null, undefined, empty string if (value == null || value === '') { return false; } // Handle strings that are just whitespace or contain only special characters if (typeof value === 'string') { const trimmed = value.trim(); if (trimmed === '' || trimmed === 'undefined' || trimmed === 'null') { return false; } // Check for patterns like '%%', '%undefined%', '%null%' if (trimmed === '%%' || trimmed.includes('undefined') || trimmed.includes('null')) { return false; } } return true; } const builder = { where(condition, value) { if (!condition || typeof condition !== 'string') { throw new Error('WHERE condition must be a non-empty string'); } if (isValidValue(value)) { whereClauses.push(condition); values.push(value); } return builder; }, groupBy(...columns) { if (columns.length) { // Validate each column name const validColumns = columns.map(col => sanitizeColumnName(col)); groupByClause = ' GROUP BY ' + validColumns.join(', '); } return builder; }, having(condition, value) { if (!condition || typeof condition !== 'string') { throw new Error('HAVING condition must be a non-empty string'); } if (isValidValue(value)) { havingClauses.push(condition); values.push(value); } return builder; }, orderBy(column, direction = 'ASC') { if (column) { const sanitizedColumn = sanitizeColumnName(column); const validatedDirection = validateDirection(direction); orderByClause = ` ORDER BY ${sanitizedColumn} ${validatedDirection}`; } return builder; }, paginate(limit, offset = 0) { // Validate limit if (limit != null) { const limitNum = Number(limit); if (isNaN(limitNum) || limitNum < 0 || !Number.isInteger(limitNum)) { throw new Error(`Invalid limit value: ${limit}. Must be a non-negative integer.`); } // Validate offset const offsetNum = Number(offset); if (isNaN(offsetNum) || offsetNum < 0 || !Number.isInteger(offsetNum)) { throw new Error(`Invalid offset value: ${offset}. Must be a non-negative integer.`); } limitClause = ' LIMIT ?'; offsetClause = ' OFFSET ?'; values.push(limitNum, offsetNum); } return builder; }, build(mode) { // 1) Build the "data" SQL let finalSql = sql; if (whereClauses.length) { // Check if base SQL already has a WHERE clause const hasExistingWhere = hasExistingWhereClause(sql); const connector = hasExistingWhere ? ' AND ' : ' WHERE '; finalSql += connector + whereClauses.join(' AND '); } if (groupByClause) { finalSql += groupByClause; } if (havingClauses.length) { finalSql += ' HAVING ' + havingClauses.join(' AND '); } finalSql += orderByClause + limitClause + offsetClause; // 2) If count mode, wrap without pagination if (mode === 'count') { // Calculate how many pagination values to remove let paginationValueCount = 0; if (limitClause) paginationValueCount += 1; if (offsetClause) paginationValueCount += 1; // Remove pagination values from the count query const countValues = values.slice(0, values.length - paginationValueCount); // rebuild inner query (no pagination) let inner = sql; if (whereClauses.length) { // Check if base SQL already has a WHERE clause const hasExistingWhere = hasExistingWhereClause(sql); const connector = hasExistingWhere ? ' AND ' : ' WHERE '; inner += connector + whereClauses.join(' AND '); } if (groupByClause) inner += groupByClause; if (havingClauses.length) inner += ' HAVING ' + havingClauses.join(' AND '); // you can omit ORDER BY in count, but including it won't change the count const countSql = `SELECT COUNT(*) AS total FROM (${inner}) AS cnt`; return { sql: finalSql, values, countSql, countValues }; } return { sql: finalSql, values }; } }; return builder; } module.exports = createSelectBuilder;