miniml
Version:
A minimal, embeddable semantic data modeling language for generating SQL queries from YAML model definitions. Inspired by LookML.
134 lines • 7.36 kB
JavaScript
import { SqlValidationError } from "./common.js";
import { validateWhereClause, validateHavingClause, validateDateInput } from "./validation.js";
import { extractFieldReferences } from "./parse.js";
import { constructDateRangeExpression, constructDateTruncExpression } from "./dialect.js";
export function renderQuery(model, { dimensions = [], measures = [], date_from, date_to, where, having, order_by = [], limit, distinct, date_granularity }) {
const where_refs = extractFieldReferences(where, model);
const having_refs = extractFieldReferences(having, model);
validateQueryInfo(model, dimensions, measures, where_refs, having_refs, 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 join_keys = Array.from(new Set([
...dimensions.map(key => model.dimensions[key].join).filter(Boolean),
...measures.map(key => model.measures[key].join).filter(Boolean),
...where_refs.map(key => model.dimensions[key].join).filter(Boolean),
...having_refs.map(key => model.measures[key].join).filter(Boolean)
]));
const undefined_joins = join_keys.filter(key => !model.join[key]);
if (undefined_joins.length > 0)
throw new SqlValidationError(`Undefined join reference: ${undefined_joins.join(', ')}`, [`Undefined join reference: ${undefined_joins.join(', ')}`], ['Add the missing join definitions to your model', 'Check for typos in join references']);
const joins = join_keys.map(key => model.join[key]);
const dimension_fields = dimensions.map(key => key === model.date_field && date_granularity ? applyDateGranularity(date_granularity, key, model.dimensions[key].sql, model.dialect) : model.dimensions[key].sql);
const measure_fields = measures.map(key => model.measures[key].sql);
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 if (model.default_date_range)
appendDefaultDateRange(where_clause, model);
}
if (where) {
const validation = validateWhereClause(where, model);
if (!validation.ok) {
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(`(${where})`);
}
if (model.where)
where_clause.push(`(${model.where})`);
if (where_clause.length > 0)
query.push(`WHERE ${expandWhereReferences(where_clause.join("\nAND "), model.dimensions)}`);
if (group_by)
query.push("GROUP BY ALL");
if (having) {
const validation = validateHavingClause(having, model);
if (!validation.ok) {
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 ${expandWhereReferences(having, model.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 appendDefaultDateRange(where_clause, model) {
if (!model.date_field || !model.default_date_range || !model.dialect)
return;
let result;
result = model.default_date_range.match(/^last\s+(\d+)\s+(hours?|days?|weeks?|months?|years?|years)$/i);
if (result)
where_clause.push(constructDateRangeExpression(model.dialect, model.date_field, parseInt(result[1]), result[2], model.include_today ?? true));
}
function applyDateGranularity(date_granularity, key, date_expr, dialect) {
const [expr, alias] = unwrapSqlExpressionAlias(date_expr);
const expr_trunc = constructDateTruncExpression(dialect, expr, date_granularity || "DAY");
return `${expr_trunc} AS ${alias ?? key}`;
}
function expandWhereReferences(where_clause, dictionary) {
if (!where_clause)
return where_clause;
let result = where_clause;
for (const key of Object.keys(dictionary)) {
const regexp = new RegExp(`\\b${key}\\b`, "g");
if (regexp.test(where_clause)) {
const { sql } = dictionary[key];
if (sql !== key) {
const unwrapped = sql?.includes(" AS ") ? sql.slice(0, sql.lastIndexOf(" AS ")).trim() : undefined;
if (unwrapped)
result = result.replaceAll(regexp, unwrapped);
}
}
}
return result;
}
function unwrapSqlExpressionAlias(exprression) {
const i = exprression.toUpperCase().lastIndexOf(" AS ");
if (i > 0) {
const expression = exprression.slice(0, i).trim();
const alias = exprression.slice(i + 4).trim();
return [expression, alias];
}
return [exprression];
}
function validateKeys(keys, dictionary) {
return keys.filter(key => !dictionary.includes(key));
}
function validateQueryInfo(model, dimensions, measures, where, having, order_by) {
const invalid_dimensions = validateKeys(dimensions, Object.keys(model.dimensions));
const invalid_measures = validateKeys(measures, Object.keys(model.measures));
const invalid_where = validateKeys(where, Object.keys(model.dimensions));
const invalid_having = validateKeys(having, 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_where.length > 0)
errors.push(`- where: ${invalid_where.join(", ")}`);
if (invalid_having.length > 0)
errors.push(`- having: ${invalid_having.join(", ")}`);
if (invalid_order.length > 0)
errors.push(`- order_by: ${invalid_order.join(", ")}`);
if (errors.length > 0)
throw new SqlValidationError(`The following keys are invalid:\n${errors.join("\n")}`, errors, ['Check that all referenced keys exist in your model']);
}
//# sourceMappingURL=query.js.map