miniml
Version:
A minimal, embeddable semantic data modeling language for generating SQL queries from YAML model definitions. Inspired by LookML.
238 lines • 8.23 kB
JavaScript
import pkg from "node-sql-parser";
const { Parser } = pkg;
import { extractFieldReferencesFromNode } from "./common.js";
export class SqlValidationError extends Error {
violations;
suggestions;
constructor(message, violations, suggestions) {
super(message);
this.violations = violations;
this.suggestions = suggestions;
this.name = 'SqlValidationError';
}
}
export class UnsafeConstructError extends SqlValidationError {
}
export class UnknownColumnError extends SqlValidationError {
}
export class ComplexityLimitError extends SqlValidationError {
}
const MAX_EXPRESSION_LENGTH = 10000;
const SAFE_FUNCTIONS = {
bigquery: [
'UPPER', 'LOWER', 'TRIM', 'LENGTH', 'SUBSTR',
'DATE', 'TIMESTAMP', 'EXTRACT', 'DATE_TRUNC',
'COALESCE', 'IFNULL', 'SAFE_CAST', 'CAST',
'CONCAT', 'REPLACE', 'SPLIT'
],
snowflake: [
'UPPER', 'LOWER', 'TRIM', 'LENGTH', 'SUBSTRING',
'TO_DATE', 'TO_TIMESTAMP', 'EXTRACT', 'DATE_TRUNC',
'COALESCE', 'NVL', 'TRY_CAST', 'CAST',
'CONCAT', 'REPLACE', 'SPLIT_PART'
]
};
const DANGEROUS_CONSTRUCTS = [
'DROP', 'ALTER', 'CREATE', 'TRUNCATE',
'INSERT', 'UPDATE', 'DELETE', 'MERGE',
'DESCRIBE', 'SHOW', 'EXPLAIN', 'ANALYZE',
'SYSTEM', 'EXEC', 'EXECUTE', 'CALL', 'LOAD_FILE'
];
const COMMENT_PATTERNS = ['--', '/*', '*/', '#'];
export function validateSqlExpression(expression, model) {
const result = {
ok: true,
errors: [],
warnings: []
};
if (!expression || expression.trim() === '')
return result;
try {
const basicValidation = performBasicSafetyChecks(expression);
if (!basicValidation.ok)
return basicValidation;
const parser = new Parser();
let ast;
try {
const wrappedSql = `SELECT * FROM dummy WHERE ${expression}`;
ast = parser.astify(wrappedSql, { database: model.dialect });
}
catch (parseError) {
result.ok = false;
result.errors.push(`Invalid SQL syntax: ${parseError?.message || 'Unknown parsing error'}`);
return result;
}
const astValidation = validateAstSafety(ast, model.dialect);
if (!astValidation.ok)
return astValidation;
const columnValidation = validateColumnReferences(ast, model);
if (!columnValidation.ok)
return columnValidation;
}
catch (error) {
result.ok = false;
result.errors.push(`Validation error: ${error?.message || 'Unknown validation error'}`);
}
return result;
}
export function validateWhereClause(where, model) {
return validateSqlExpression(where, model);
}
export function validateHavingClause(having, model) {
return validateSqlExpression(having, model);
}
export function validateDateInput(date) {
if (!date || date.trim() === '') {
return true;
}
const dateRegex = /^(\d{4})-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])(\s([01]\d|2[0-3]):[0-5]\d:[0-5]\d)?$/;
if (!dateRegex.test(date)) {
return false;
}
const datePart = date.split(' ')[0];
const parsedDate = new Date(datePart + 'T00:00:00Z');
const [year, month, day] = datePart.split('-').map(Number);
return parsedDate.getUTCFullYear() === year &&
parsedDate.getUTCMonth() === month - 1 &&
parsedDate.getUTCDate() === day;
}
function performBasicSafetyChecks(expression) {
const result = {
ok: true,
errors: [],
warnings: []
};
for (const construct of DANGEROUS_CONSTRUCTS) {
const regex = new RegExp(`\\b${construct}\\b`, 'i');
if (regex.test(expression)) {
result.ok = false;
result.errors.push(`Dangerous SQL construct detected: ${construct}`);
}
}
for (const pattern of COMMENT_PATTERNS) {
if (expression.includes(pattern)) {
result.ok = false;
result.errors.push(`SQL comments are not allowed: ${pattern}`);
}
}
if (/\bUNION\b/i.test(expression)) {
result.ok = false;
result.errors.push('UNION statements are not allowed in filter expressions');
}
if (/\bSELECT\b/i.test(expression)) {
result.ok = false;
result.errors.push('Subqueries are not allowed. Use simple comparisons instead.');
}
if (expression.length > MAX_EXPRESSION_LENGTH) {
result.ok = false;
result.errors.push(`SQL expression exceeds maximum length=${MAX_EXPRESSION_LENGTH}.`);
}
let depth = 0;
let maxDepth = 0;
for (const char of expression) {
if (char === '(') {
depth++;
maxDepth = Math.max(maxDepth, depth);
}
else if (char === ')') {
depth--;
}
}
if (maxDepth > 10) {
result.ok = false;
result.errors.push('Expression too complex. Maximum nesting depth is 10 levels.');
}
return result;
}
function validateAstSafety(ast, dialect) {
const result = {
ok: true,
errors: [],
warnings: []
};
const safeFunctions = SAFE_FUNCTIONS[dialect];
let nodeCount = 0;
function validateNode(node, depth = 0) {
if (!node || typeof node !== 'object') {
return;
}
nodeCount++;
if (nodeCount > 100) {
result.ok = false;
result.errors.push('Expression too complex. Maximum of 100 AST nodes allowed.');
return;
}
if (depth > 10) {
result.ok = false;
result.errors.push('Expression nesting too deep. Maximum depth is 10 levels.');
return;
}
if (node.type) {
switch (node.type.toLowerCase()) {
case 'select':
case 'insert':
case 'update':
case 'delete':
case 'create':
case 'drop':
case 'alter':
result.ok = false;
result.errors.push(`${node.type} statements are not allowed in filter expressions`);
return;
}
}
if (node.type === 'function' && node.name) {
const funcName = node.name.toUpperCase();
if (!safeFunctions.includes(funcName)) {
result.ok = false;
result.errors.push(`Function '${funcName}' is not allowed. Safe functions: ${safeFunctions.join(', ')}`);
return;
}
}
for (const key in node) {
if (key !== 'type' && key !== 'name') {
const value = node[key];
if (Array.isArray(value)) {
value.forEach(item => validateNode(item, depth + 1));
}
else if (typeof value === 'object') {
validateNode(value, depth + 1);
}
}
}
}
if (ast && Array.isArray(ast)) {
const selectStmt = ast[0];
if (selectStmt && selectStmt.where) {
validateNode(selectStmt.where, 0);
}
}
return result;
}
function validateColumnReferences(ast, model) {
const result = {
ok: true,
errors: [],
warnings: []
};
const availableColumns = new Set([
...Object.keys(model.dimensions),
...Object.keys(model.measures)
]);
const reference_fields = new Set();
if (ast && typeof ast === 'object') {
const selectStmt = Array.isArray(ast) ? ast[0] : ast;
if (selectStmt && selectStmt.where)
extractFieldReferencesFromNode(selectStmt.where, reference_fields);
}
for (const key of reference_fields)
if (!availableColumns.has(key)) {
result.ok = false;
const available = Array.from(availableColumns).slice(0, 10).join(', ');
const totalCount = availableColumns.size;
const availableText = totalCount > 10 ? `${available} (and ${totalCount - 10} more)` : available;
result.errors.push(`Field '${key}' not found. Available columns: ${availableText}`);
}
return result;
}
//# sourceMappingURL=validation.js.map