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