UNPKG

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