UNPKG

miniml

Version:

A minimal, embeddable semantic data modeling language for generating SQL queries from YAML model definitions. Inspired by LookML.

102 lines 5.28 kB
import { SqlValidationError } from "./common.js"; import { validateWhereClause, validateHavingClause, validateDateInput } from "./validation.js"; export function renderQuery(model, { dimensions = [], measures = [], date_from, date_to, where, having, order_by = [], limit, distinct, date_granularity }) { validateQueryInfo(model, dimensions, measures, order_by); if (date_from && !validateDateInput(date_from)) throw new SqlValidationError(`Invalid date_from format: ${date_from}`, ['Invalid date format'], ['Use YYYY-MM-DD format (e.g., "2024-01-01")']); if (date_to && !validateDateInput(date_to)) throw new SqlValidationError(`Invalid date_to format: ${date_to}`, ['Invalid date format'], ['Use YYYY-MM-DD format (e.g., "2024-01-31")']); const dimension_fields = dimensions.map(key => model.dimensions[key].sql); const measure_fields = measures.map(key => model.measures[key].sql); if (date_granularity) { const i = dimension_fields.findIndex(key => key === model.date_field); if (i >= 0) dimension_fields[i] = `DATE_TRUNC('${date_granularity.toUpperCase()}', ${model.date_field}) AS ${model.date_field}`; } const joins = Array.from(new Set([ ...dimensions.map(key => model.dimensions[key].join).filter(Boolean), ...measures.map(key => model.measures[key].join).filter(Boolean) ])).map(key => model.join[key]); const group_by = dimensions.length > 0 && measures.length > 0; const query = [ distinct && !group_by ? "SELECT DISTINCT" : "SELECT", [ ...dimension_fields, ...measure_fields ].map(text => ` ${text}`).join(",\n"), `FROM ${model.from}`, ...joins ]; const where_clause = []; if (model.date_field) { if (date_from && date_to) where_clause.push(`${model.date_field} BETWEEN '${date_from}' AND '${date_to}'`); else if (date_from) where_clause.push(`${model.date_field} >= '${date_from}'`); else if (date_to) where_clause.push(`${model.date_field} <= '${date_to}'`); else { const days = model.default_date_range_days ?? 30; where_clause.push(`${model.date_field} >= DATEADD(DAY, -${days}, CURRENT_DATE)`); } } if (where) { const validation = validateWhereClause(where, model); if (!validation.isValid) { throw new SqlValidationError(`Invalid WHERE clause: ${validation.errors.join(', ')}`, validation.errors, ['Use simple comparisons like "column = value"', 'Check column names against your model']); } where_clause.push(`(${expandFilterReferences(where, model.dimensions, dimensions)})`); } if (model.where) where_clause.push(`(${model.where})`); if (where_clause.length > 0) query.push(`WHERE ${where_clause.join("\nAND ")}`); if (group_by) query.push("GROUP BY ALL"); if (having) { const validation = validateHavingClause(having, model); if (!validation.isValid) { throw new SqlValidationError(`Invalid HAVING clause: ${validation.errors.join(', ')}`, validation.errors, ['Use simple comparisons like "measure > value"', 'Reference only measures defined in your model']); } query.push(`HAVING ${expandFilterReferences(having, model.measures, measures)}`); } if (order_by.length > 0) query.push(`ORDER BY ${order_by.map(key => !key.startsWith("-") ? key : `${key.slice(1)} DESC`).join(", ")}`); if (limit && !isNaN(limit) && limit > 0) query.push(`LIMIT ${limit}`); return query.filter(Boolean).join("\n"); } function expandFilterReferences(filter, dictionary, references) { let result = filter; if (result) { const keys = Object.keys(dictionary).filter(key => !references.includes(key)); for (const key of keys) { const regexp = new RegExp(`\\b${key}\\b`); if (regexp.test(filter)) { const { sql } = dictionary[key]; const unwrapped = sql?.includes(" AS ") ? sql.slice(0, sql.lastIndexOf(" AS ")).trim() : undefined; if (unwrapped) result = result.replaceAll(key, unwrapped); } } } return result; } function validateKeys(keys, dictionary) { return keys.filter(key => !dictionary.includes(key)); } function validateQueryInfo(model, dimensions, measures, order_by) { const invalid_dimensions = validateKeys(dimensions, Object.keys(model.dimensions)); const invalid_measures = validateKeys(measures, Object.keys(model.measures)); const invalid_order = validateKeys(order_by.map(key => key.startsWith("-") ? key.slice(1) : key), [...Object.keys(model.dimensions), ...Object.keys(model.measures)]); const errors = []; if (invalid_dimensions.length > 0) errors.push(`- dimensions: ${invalid_dimensions.join(", ")}`); if (invalid_measures.length > 0) errors.push(`- measures: ${invalid_measures.join(", ")}`); if (invalid_order.length > 0) errors.push(`- order_by: ${invalid_order.join(", ")}`); if (errors.length > 0) throw `The following keys are invalid:\n${errors.join("\n")}`; } //# sourceMappingURL=query.js.map