miniml
Version:
A minimal, embeddable semantic data modeling language for generating SQL queries from YAML model definitions. Inspired by LookML.
164 lines • 8.82 kB
JavaScript
import { SqlValidationError } from "./common.js";
import { validateWhereClause, validateHavingClause, validateDateInput } from "./validation.js";
import { extractFieldReferences } from "./parse.js";
import { constructDateRangeExpression, constructDateTruncExpression, normalizeQuotesForBigQuery } from "./dialect.js";
export function renderQuery(model, { dimensions = [], measures = [], date_from, date_to, where, having, order_by = [], limit, distinct, date_granularity }) {
if (model.dialect === "bigquery") {
where = normalizeQuotesForBigQuery(where);
having = normalizeQuotesForBigQuery(having);
}
const where_refs = extractFieldReferences(where, model);
const having_refs = extractFieldReferences(having, model);
validateQueryInfo(model, dimensions, measures, where_refs, having_refs, order_by);
for (const raw of order_by) {
const key = raw.startsWith("-") ? raw.slice(1) : raw;
if (model.dimensions[key]) {
if (!dimensions.includes(key))
dimensions = [...dimensions, key];
}
else if (model.measures[key] && !measures.includes(key))
measures = [...measures, key];
}
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 referenced_joins = 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 always_joins = new Set(model.always_join || []);
const all_required_joins = new Set([...referenced_joins, ...always_joins]);
const undefined_joins = Array.from(all_required_joins).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 = Object.keys(model.join)
.filter(key => all_required_joins.has(key))
.map(key => model.join[key]);
const dimension_fields = dimensions.map(key => {
if (key === model.date_field && date_granularity)
return applyDateGranularity(date_granularity, key, model.dimensions[key].sql, model.dialect);
const sql = model.dimensions[key].sql;
return sql !== key && !/\sAS\s/i.test(sql) ? `${sql} AS ${key}` : sql;
});
const projected_measures = [...measures, ...having_refs.filter(key => !measures.includes(key))];
const measure_fields = projected_measures.map(key => model.measures[key].sql);
const group_by = dimensions.length > 0 && projected_measures.length > 0;
const select_list = [...dimension_fields, ...measure_fields];
const query = [
select_list.length === 0
? "SELECT *"
: (distinct && !group_by ? "SELECT DISTINCT\n" : "SELECT\n") + select_list.map(text => ` ${text}`).join(",\n"),
`FROM ${model.from}`,
...joins
];
const where_clause = [];
if (model.date_field) {
const from = date_from ? date_from.slice(0, 10) : date_from;
if (from && date_to) {
where_clause.push(`${model.date_field} >= '${from}' AND ${model.date_field} < '${nextDayLiteral(date_to)}'`);
}
else if (from)
where_clause.push(`${model.date_field} >= '${from}'`);
else if (date_to)
where_clause.push(`${model.date_field} < '${nextDayLiteral(date_to)}'`);
else if (model.default_date_range && date_from !== null && date_from !== null)
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 ${having}`);
}
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 nextDayLiteral(date) {
const [y, m, d] = date.slice(0, 10).split("-").map(Number);
const next = new Date(Date.UTC(y, m - 1, d + 1));
return next.toISOString().slice(0, 10);
}
function appendDefaultDateRange(where_clause, model) {
if (!model.date_field || !model.default_date_range || !model.dialect)
return;
let result;
const is_date = model.date_type?.toUpperCase() === "DATE";
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, is_date));
}
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