@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
JavaScript
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;