SOURCE CODE

The code

RooQL is one JavaScript file with no dependencies. It compiles metric queries into Snowflake, DuckDB or BigQuery SQL: no network calls, no storage, no credentials. Download it, check it, and read every line below.

Download

rooql.js

The browser build. Claude artifacts inline this file, and it's the one to ship in your app.

45 KB · 16 KB gzipped

Download rooql.js

rooql.readable.js

The same build, not minified, so it's easy to review. It behaves exactly like rooql.js.

77 KB

Download readable

Check your copy

The SHA-256 checksum of rooql.js is a2a23a7638a3ddda3f8361c2283781d8120224bc980b986b698b76fd4a8e3e8c. Compare it with your copy:

curl -O https://rooql.com/rooql.js
shasum -a 256 rooql.js

Use it

The file defines a global rooql. Load it before your own script, or paste its contents into a <script> tag, unchanged. The docs cover models, queries and errors.

<script src="rooql.js"></script>
<script type="module">
  const model = await rooql.loadOssieModel(modelJsonText);
  const compiler = rooql.createRooql({ model, dialect: "snowflake" }); // or "duckdb" / "bigquery"
  const { sql, columns, modelHash } = compiler.compile({ metrics: ["revenue"] });
</script>

Asking Claude to build with RooQL? Point it at rooql.com/llms.txt.

Read it

This is rooql.readable.js in full. Each // src/… comment starts one module of the source:

/* RooQL compiler, readable build (global `rooql`). Same code as rooql.js, not minified. Generated by npm run build; do not edit. */
var rooql = (() => {
  var __defProp = Object.defineProperty;
  var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
  var __getOwnPropNames = Object.getOwnPropertyNames;
  var __hasOwnProp = Object.prototype.hasOwnProperty;
  var __export = (target, all) => {
    for (var name in all)
      __defProp(target, name, { get: all[name], enumerable: true });
  };
  var __copyProps = (to, from, except, desc) => {
    if (from && typeof from === "object" || typeof from === "function") {
      for (let key2 of __getOwnPropNames(from))
        if (!__hasOwnProp.call(to, key2) && key2 !== except)
          __defProp(to, key2, { get: () => from[key2], enumerable: !(desc = __getOwnPropDesc(from, key2)) || desc.enumerable });
    }
    return to;
  };
  var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);

  // src/index.js
  var index_exports = {};
  __export(index_exports, {
    OSSIE_REVISION: () => OSSIE_REVISION,
    OSSIE_VERSION: () => OSSIE_VERSION,
    RooqlError: () => RooqlError,
    createRooql: () => createRooql,
    loadOssieModel: () => loadOssieModel
  });

  // src/errors.js
  var RooqlError = class extends Error {
    constructor(code, message, path) {
      super(path ? `${path}: ${message}` : message);
      this.name = "RooqlError";
      this.code = code;
      if (path) this.path = path;
    }
  };
  function fail(code, message, path) {
    throw new RooqlError(code, message, path);
  }

  // src/functions.js
  var call = (name) => (args) => `${name}(${args.join(", ")})`;
  var entries = (names2, args, type, options = {}) => Object.fromEntries(names2.split(" ").map((name) => [name, { kind: "scalar", args: [args, args], type, ...options }]));
  var weekStart = (date, dialect) => dialect === "duckdb" ? `DATE_TRUNC('week', ${date})` : `DATEADD(day, 1 - DAYOFWEEKISO(${date}), DATE_TRUNC('day', ${date}))`;
  var caseWhen = ([condition, yes, no]) => `CASE WHEN ${condition} THEN ${yes} ELSE ${no} END`;
  var DATE_PARTS = ["year", "quarter", "month", "week", "day", "hour", "minute", "second"];
  var DUCKDB_INTERVAL = { year: "to_years", quarter: "to_months", month: "to_months", week: "to_weeks", day: "to_days", hour: "to_hours", minute: "to_minutes", second: "to_seconds" };
  var FUNCTIONS = {
    // Aggregates
    SUM: { kind: "aggregate", args: [1, 1], distinct: true, numeric: true, type: "same-numeric" },
    COUNT: { kind: "aggregate", args: [1, 1], distinct: true, star: true, type: "Integer" },
    AVG: { kind: "aggregate", args: [1, 1], numeric: true, type: "average" },
    ...entries("MIN MAX", 1, "same", { kind: "aggregate", duplicateSafe: true }),
    MEDIAN: { kind: "aggregate", args: [1, 1], numeric: true, type: "average" },
    ...entries("STDDEV STDDEV_SAMP STDDEV_POP VARIANCE VAR_SAMP VAR_POP", 1, "Float", { kind: "aggregate", numeric: true }),
    APPROX_COUNT_DISTINCT: { kind: "aggregate", args: [1, 1], duplicateSafe: true, type: "Integer" },
    // Conditional
    COALESCE: { kind: "scalar", args: [2, 32], type: "widest" },
    ...entries("IFNULL NVL", 2, "widest", { render: call("COALESCE") }),
    NVL2: { kind: "scalar", args: [3, 3], type: "widest-tail", render: ([x, a, b]) => `CASE WHEN ${x} IS NOT NULL THEN ${a} ELSE ${b} END` },
    NULLIF: { kind: "scalar", args: [2, 2], type: "first" },
    ...entries("IF IFF", 3, "widest-tail", { render: caseWhen }),
    ZEROIFNULL: { kind: "scalar", args: [1, 1], type: "first", render: ([x]) => `COALESCE(${x}, 0)` },
    NULLIFZERO: { kind: "scalar", args: [1, 1], type: "first", render: ([x]) => `NULLIF(${x}, 0)` },
    // Maths
    ...entries("ABS FLOOR CEIL", 1, "first"),
    ROUND: { kind: "scalar", args: [1, 2], type: "first" },
    CEILING: { kind: "scalar", args: [1, 1], type: "first", render: call("CEIL") },
    SIGN: { kind: "scalar", args: [1, 1], type: "Integer" },
    MOD: { kind: "scalar", args: [2, 2], type: "first" },
    POWER: { kind: "scalar", args: [2, 2], type: "Float" },
    ...entries("SQRT EXP LN", 1, "Float"),
    // Strings
    CONCAT: { kind: "scalar", args: [1, 32], type: "String", render: (args) => args.length === 1 ? `CAST(${args[0]} AS VARCHAR)` : `(${args.join(" || ")})` },
    ...entries("LOWER UPPER TRIM LTRIM RTRIM", 1, "String"),
    LENGTH: { kind: "scalar", args: [1, 1], type: "Integer" },
    ...entries("LEFT RIGHT", 2, "String"),
    SUBSTRING: { kind: "scalar", args: [2, 3], type: "String" },
    SUBSTR: { kind: "scalar", args: [2, 3], type: "String", render: call("SUBSTRING") },
    REPLACE: { kind: "scalar", args: [3, 3], type: "String" },
    SPLIT_PART: { kind: "scalar", args: [3, 3], type: "String" },
    CONTAINS: { kind: "scalar", args: [2, 2], type: "Boolean" },
    STARTSWITH: { kind: "scalar", args: [2, 2], type: "Boolean", render: (args, dialect) => call(dialect === "duckdb" ? "starts_with" : "STARTSWITH")(args) },
    ENDSWITH: { kind: "scalar", args: [2, 2], type: "Boolean", render: (args, dialect) => call(dialect === "duckdb" ? "ends_with" : "ENDSWITH")(args) },
    // Dates and times
    CURRENT_DATE: { kind: "scalar", args: [0, 0], type: "Date", render: () => "CURRENT_DATE" },
    CURRENT_TIMESTAMP: { kind: "scalar", args: [0, 0], type: "DateTimeTz", render: () => "CURRENT_TIMESTAMP" },
    ...entries("YEAR QUARTER MONTH DAY DAYOFYEAR HOUR MINUTE SECOND", 1, "Integer"),
    WEEK: { kind: "scalar", args: [1, 1], type: "Integer", render: (args, dialect) => call(dialect === "duckdb" ? "WEEK" : "WEEKISO")(args) },
    DATE_TRUNC: { kind: "scalar", args: [2, 2], datePart: 0, type: "second", render: ([part2, date], dialect, node) => node.part === "week" ? weekStart(date, dialect) : `DATE_TRUNC(${part2}, ${date})` },
    DATEADD: {
      kind: "scalar",
      args: [3, 3],
      datePart: 0,
      type: "dateadd",
      render: ([part2, amount, date], dialect, node) => {
        const unit = node.part;
        if (dialect !== "duckdb") return `DATEADD(${unit}, ${amount}, ${date})`;
        const shift = `${DUCKDB_INTERVAL[unit]}(${unit === "quarter" ? `3 * (${amount})` : amount})`;
        return node.dateTyped ? `CAST((${date} + ${shift}) AS DATE)` : `(${date} + ${shift})`;
      }
    },
    DATEDIFF: {
      kind: "scalar",
      args: [3, 3],
      datePart: 0,
      type: "Integer",
      render: ([, start, end], dialect, node) => {
        const diff = (part2, a, b) => dialect === "duckdb" ? `date_diff('${part2}', ${a}, ${b})` : `DATEDIFF(${part2}, ${a}, ${b})`;
        return node.part === "week" ? `CAST((${diff("day", weekStart(start, dialect), weekStart(end, dialect))} / 7) AS BIGINT)` : diff(node.part, start, end);
      }
    }
  };
  var GRAINS = ["year", "quarter", "month", "week", "day", "hour", "minute"];
  function functionSpec(node, path) {
    const spec = Object.hasOwn(FUNCTIONS, node.name) ? FUNCTIONS[node.name] : null;
    if (!spec) fail("UNSUPPORTED_FUNCTION", `${node.name} is not a supported function`, path);
    const [min, max] = spec.args;
    if (node.args.length < min || node.args.length > max) {
      fail("UNSUPPORTED_EXPRESSION", `${node.name} takes ${min === max ? min : `${min} to ${max}`} argument${max === 1 ? "" : "s"}`, path);
    }
    if (node.distinct && !spec.distinct) fail("UNSUPPORTED_EXPRESSION", `${node.name} does not support DISTINCT`, path);
    if (node.args.some((arg) => arg.type === "star") && !(spec.star && node.args.length === 1 && !node.distinct)) {
      fail("UNSUPPORTED_EXPRESSION", `* is only allowed as COUNT(*)`, path);
    }
    if (spec.datePart !== void 0) {
      const partNode = node.args[spec.datePart];
      const raw = partNode.type === "lit" && partNode.kind === "string" ? partNode.value : null;
      const part2 = raw?.toLowerCase();
      if (!part2 || !DATE_PARTS.includes(part2) || node.name === "DATE_TRUNC" && part2 === "second") {
        fail("UNSUPPORTED_EXPRESSION", `${node.name} needs a date part: ${DATE_PARTS.filter((p) => node.name !== "DATE_TRUNC" || p !== "second").join(", ")}`, path);
      }
      node.part = part2;
    }
    return spec;
  }

  // src/identifiers.js
  function semanticName(value, path) {
    if (typeof value !== "string" || value.length > 128 || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(value)) {
      fail("INVALID_NAME", "Expected an ASCII semantic name of at most 128 characters beginning with a letter or underscore", path);
    }
    return value;
  }
  function quoteIdentifier(value) {
    return `"${value.replaceAll('"', '""')}"`;
  }
  function outputIdentifier(value, dialect) {
    if (dialect !== "bigquery") return value;
    const parts = [...value.matchAll(/"((?:[^"]|"")*)"/g)].map((match) => match[1].replaceAll('""', '"'));
    return "`" + parts.join(".").replaceAll("\\", "\\\\").replaceAll("`", "\\`") + "`";
  }
  function physicalIdentifier(value, maxParts, path, uppercase = true) {
    if (typeof value !== "string" || !value.isWellFormed()) fail("INVALID_IDENTIFIER", "Expected a physical identifier", path);
    const parts = [];
    let offset = 0;
    while (offset < value.length) {
      let part2;
      if (value[offset] === '"') {
        offset++;
        part2 = "";
        let closed = false;
        while (offset < value.length) {
          if (value[offset] === '"') {
            if (value[offset + 1] === '"') {
              part2 += '"';
              offset += 2;
            } else {
              offset++;
              closed = true;
              break;
            }
          } else {
            part2 += value[offset++];
          }
        }
        if (!closed) fail("INVALID_IDENTIFIER", "Unclosed quoted identifier", path);
      } else {
        const match = /^[A-Za-z_][A-Za-z0-9_$]*/.exec(value.slice(offset));
        if (!match) fail("INVALID_IDENTIFIER", "Expected a physical identifier, not SQL", path);
        part2 = uppercase ? match[0].toUpperCase() : match[0];
        offset += match[0].length;
      }
      if (!part2.length || [...part2].length > 255 || /[\u0000-\u001f\u007f-\u009f]/.test(part2)) {
        fail("INVALID_IDENTIFIER", "Identifier must contain 1 to 255 characters without control characters", path);
      }
      parts.push(part2);
      if (offset === value.length) break;
      if (value[offset++] !== "." || offset === value.length) {
        fail("INVALID_IDENTIFIER", "Unexpected text after physical identifier", path);
      }
    }
    if (!parts.length || parts.length > maxParts) {
      fail("INVALID_IDENTIFIER", `Expected 1 to ${maxParts} identifier parts`, path);
    }
    return parts.map(quoteIdentifier).join(".");
  }

  // src/parse.js
  var MAX_LENGTH = 16384;
  var MAX_TOKENS = 2048;
  var MAX_DEPTH = 64;
  var KEYWORDS = /* @__PURE__ */ new Set(["AND", "OR", "NOT", "IS", "NULL", "IN", "BETWEEN", "LIKE", "ILIKE", "CASE", "WHEN", "THEN", "ELSE", "END", "TRUE", "FALSE", "DISTINCT", "AS", "FROM", "OVER", "WITHIN", "SELECT"]);
  var TYPED_LITERALS = { DATE: "DATE", TIME: "TIME", TIMESTAMP: "TIMESTAMP", TIMESTAMP_NTZ: "TIMESTAMP" };
  var NILADIC = /* @__PURE__ */ new Set(["CURRENT_DATE", "CURRENT_TIMESTAMP", "CURRENT_TIME"]);
  var EXTRACT_PARTS = /* @__PURE__ */ new Set(["YEAR", "QUARTER", "MONTH", "WEEK", "DAY", "HOUR", "MINUTE", "SECOND"]);
  var CAST_TYPES = {
    VARCHAR: "VARCHAR",
    STRING: "VARCHAR",
    TEXT: "VARCHAR",
    INTEGER: "BIGINT",
    INT: "BIGINT",
    BIGINT: "BIGINT",
    DECIMAL: "DECIMAL",
    NUMERIC: "DECIMAL",
    NUMBER: "DECIMAL",
    FLOAT: "DOUBLE",
    DOUBLE: "DOUBLE",
    BOOLEAN: "BOOLEAN",
    DATE: "DATE",
    TIMESTAMP: "TIMESTAMP",
    TIMESTAMP_NTZ: "TIMESTAMP",
    TIME: "TIME"
  };
  function tokenize(text, path) {
    if (typeof text !== "string" || !text.trim().length || text.length > MAX_LENGTH) {
      fail("UNSUPPORTED_EXPRESSION", `Expected an expression of 1 to ${MAX_LENGTH} characters`, path);
    }
    const tokens = [];
    let i = 0;
    const at = () => `at character ${i}`;
    while (i < text.length) {
      const c = text[i];
      if (/\s/.test(c)) {
        i++;
        continue;
      }
      if (text.startsWith("--", i) || text.startsWith("/*", i)) fail("UNSUPPORTED_EXPRESSION", `Comments are not allowed in expressions (${at()})`, path);
      if (c === "'" || c === '"') {
        let value = "";
        let j = i + 1;
        for (; ; ) {
          if (j >= text.length) fail("UNSUPPORTED_EXPRESSION", `Unclosed ${c === "'" ? "string" : "quoted identifier"} (${at()})`, path);
          if (text[j] === c) {
            if (text[j + 1] === c) {
              value += c;
              j += 2;
              continue;
            }
            break;
          }
          value += text[j++];
        }
        tokens.push({ t: c === "'" ? "str" : "qid", v: value, at: i });
        i = j + 1;
      } else if (/[0-9.]/.test(c) && /^(\d+\.?\d*|\.\d+)/.test(text.slice(i))) {
        const match = /^(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?/.exec(text.slice(i));
        tokens.push({ t: "num", v: match[0], at: i });
        i += match[0].length;
      } else if (/[A-Za-z_]/.test(c)) {
        const match = /^[A-Za-z_][A-Za-z0-9_$]*/.exec(text.slice(i));
        tokens.push({ t: "id", v: match[0], up: match[0].toUpperCase(), at: i });
        i += match[0].length;
      } else {
        const op = ["<=", ">=", "<>", "!=", "||"].find((o) => text.startsWith(o, i)) || ("=<>+-*/%(),.".includes(c) ? c : null);
        if (!op) fail("UNSUPPORTED_EXPRESSION", `Unexpected character ${JSON.stringify(c)} (${at()})`, path);
        tokens.push({ t: "op", v: op === "!=" ? "<>" : op, at: i });
        i += op.length;
      }
      if (tokens.length > MAX_TOKENS) fail("UNSUPPORTED_EXPRESSION", `Expression exceeds ${MAX_TOKENS} tokens`, path);
    }
    return tokens;
  }
  function parseExpression(text, path) {
    const tokens = tokenize(text, path);
    let p = 0;
    let depth = 0;
    const peek = (o = 0) => tokens[p + o];
    const isKw = (word, o = 0) => peek(o)?.t === "id" && peek(o).up === word;
    const isOp = (op, o = 0) => peek(o)?.t === "op" && peek(o).v === op;
    const where = () => peek() ? `at character ${peek().at}` : "at the end";
    const error = (message) => fail("UNSUPPORTED_EXPRESSION", `${message} ${where()}`, path);
    const takeKw = (word) => {
      if (!isKw(word)) error(`Expected ${word}`);
      p++;
    };
    const takeOp = (op) => {
      if (!isOp(op)) error(`Expected ${op}`);
      p++;
    };
    const nest = (fn) => {
      if (++depth > MAX_DEPTH) error("Expression nesting is too deep");
      try {
        return fn();
      } finally {
        depth--;
      }
    };
    const expr = () => nest(or);
    const or = () => {
      let node2 = and();
      while (isKw("OR")) {
        p++;
        node2 = { type: "binary", op: "OR", left: node2, right: and() };
      }
      return node2;
    };
    const and = () => {
      let node2 = not();
      while (isKw("AND")) {
        p++;
        node2 = { type: "binary", op: "AND", left: node2, right: not() };
      }
      return node2;
    };
    const not = () => {
      if (isKw("NOT")) {
        p++;
        return { type: "unary", op: "NOT", arg: nest(not) };
      }
      return predicate2();
    };
    const predicate2 = () => {
      const left = additive();
      const op = peek();
      if (op?.t === "op" && ["=", "<>", "<", ">", "<=", ">="].includes(op.v)) {
        p++;
        return { type: "binary", op: op.v, left, right: additive() };
      }
      if (isKw("IS")) {
        p++;
        const negated2 = isKw("NOT") ? (p++, true) : false;
        if (isKw("NULL")) {
          p++;
          return { type: "isnull", arg: left, negated: negated2 };
        }
        if (isKw("DISTINCT")) {
          p++;
          takeKw("FROM");
          return { type: "distinctfrom", left, right: additive(), negated: negated2 };
        }
        error("Expected NULL or DISTINCT FROM after IS");
      }
      const negated = isKw("NOT") && (isKw("IN", 1) || isKw("BETWEEN", 1) || isKw("LIKE", 1) || isKw("ILIKE", 1)) ? (p++, true) : false;
      if (isKw("IN")) {
        p++;
        takeOp("(");
        if (isKw("SELECT")) error("Subqueries are not supported");
        const list = [expr()];
        while (isOp(",")) {
          p++;
          list.push(expr());
        }
        takeOp(")");
        return { type: "in", arg: left, list, negated };
      }
      if (isKw("BETWEEN")) {
        p++;
        const low = additive();
        takeKw("AND");
        return { type: "between", arg: left, low, high: additive(), negated };
      }
      if (isKw("LIKE") || isKw("ILIKE")) {
        const ilike = peek().up === "ILIKE";
        p++;
        return { type: "like", arg: left, pattern: additive(), ilike, negated };
      }
      if (negated) error("Expected IN, BETWEEN or LIKE after NOT");
      return left;
    };
    const additive = () => {
      let node2 = multiplicative();
      while (isOp("+") || isOp("-") || isOp("||")) {
        const op = tokens[p++].v;
        node2 = { type: "binary", op, left: node2, right: multiplicative() };
      }
      return node2;
    };
    const multiplicative = () => {
      let node2 = unary();
      while (isOp("*") || isOp("/") || isOp("%")) {
        const op = tokens[p++].v;
        node2 = { type: "binary", op, left: node2, right: unary() };
      }
      return node2;
    };
    const unary = () => {
      if (isOp("-") || isOp("+")) {
        const op = tokens[p++].v;
        const arg = nest(unary);
        if (op === "-" && arg.type === "lit" && arg.kind === "number" && !arg.value.startsWith("-")) return { ...arg, value: `-${arg.value}` };
        return op === "-" ? { type: "unary", op: "-", arg } : arg;
      }
      return primary();
    };
    const identifierPart = () => {
      const token = peek();
      if (token?.t === "qid") {
        p++;
        return { name: token.v, quoted: true };
      }
      if (token?.t === "id" && !KEYWORDS.has(token.up)) {
        p++;
        return { name: token.v, quoted: false };
      }
      error("Expected an identifier");
    };
    const castType = () => {
      const token = peek();
      if (token?.t !== "id" || !CAST_TYPES[token.up]) error("Unsupported CAST target type");
      p++;
      const name = CAST_TYPES[token.up];
      if (name !== "DECIMAL") {
        if (isOp("(")) error(`${token.v} does not take a size here`);
        return { name };
      }
      if (!isOp("(")) error("DECIMAL needs an explicit precision and scale, like DECIMAL(18, 2)");
      p++;
      const precision = peek();
      if (precision?.t !== "num" || !/^\d+$/.test(precision.v)) error("Expected a precision");
      p++;
      takeOp(",");
      const scale = peek();
      if (scale?.t !== "num" || !/^\d+$/.test(scale.v)) error("Expected a scale");
      p++;
      takeOp(")");
      if (Number(precision.v) < 1 || Number(precision.v) > 38 || Number(scale.v) > Number(precision.v)) error("DECIMAL precision must be 1 to 38 and at least the scale");
      return { name, precision: Number(precision.v), scale: Number(scale.v) };
    };
    const primary = () => {
      const token = peek();
      if (!token) error("Unexpected end of expression");
      if (token.t === "num") {
        p++;
        if (/[eE]/.test(token.v)) error("Exponent notation is not supported in numbers");
        return { type: "lit", kind: "number", value: token.v.startsWith(".") ? `0${token.v}` : token.v.replace(/\.$/, "") };
      }
      if (token.t === "str") {
        p++;
        return { type: "lit", kind: "string", value: token.v };
      }
      if (isOp("(")) {
        p++;
        if (isKw("SELECT")) error("Subqueries are not supported");
        const node2 = expr();
        takeOp(")");
        return node2;
      }
      if (isOp("*")) error("* is only allowed inside COUNT(*)");
      if (token.t === "id") {
        const up = token.up;
        if (up === "NULL") {
          p++;
          return { type: "lit", kind: "null" };
        }
        if (up === "TRUE" || up === "FALSE") {
          p++;
          return { type: "lit", kind: "boolean", value: up === "TRUE" };
        }
        if (up === "CASE") return caseExpression();
        if (TYPED_LITERALS[up] && peek(1)?.t === "str") {
          p += 2;
          return { type: "typed", kind: TYPED_LITERALS[up], value: tokens[p - 1].v };
        }
        if (NILADIC.has(up) && !isOp("(", 1)) {
          p++;
          return { type: "call", name: up, args: [] };
        }
        if ((up === "CAST" || up === "TRY_CAST") && isOp("(", 1)) {
          p += 2;
          const arg = expr();
          takeKw("AS");
          const to = castType();
          takeOp(")");
          return { type: "cast", arg, to, try: up === "TRY_CAST" };
        }
        if (up === "EXTRACT" && isOp("(", 1)) {
          p += 2;
          const part2 = peek();
          if (part2?.t !== "id" || !EXTRACT_PARTS.has(part2.up)) error("Unsupported EXTRACT part");
          p++;
          takeKw("FROM");
          const arg = expr();
          takeOp(")");
          return { type: "call", name: part2.up, args: [arg] };
        }
        if (isOp("(", 1) && !KEYWORDS.has(up)) return call2();
      }
      const parts = [identifierPart()];
      while (isOp(".")) {
        p++;
        parts.push(identifierPart());
      }
      return { type: "ref", parts };
    };
    const call2 = () => {
      const name = tokens[p].up;
      p += 2;
      const node2 = { type: "call", name, args: [] };
      if (isOp("*")) {
        p++;
        node2.args.push({ type: "star" });
      } else if (!isOp(")")) {
        if (isKw("DISTINCT")) {
          p++;
          node2.distinct = true;
        }
        node2.args.push(expr());
        while (isOp(",")) {
          p++;
          node2.args.push(expr());
        }
      }
      takeOp(")");
      const part2 = node2.args[0];
      if (["DATEADD", "DATEDIFF"].includes(name) && part2?.type === "ref" && part2.parts.length === 1 && !part2.parts[0].quoted) {
        node2.args[0] = { type: "lit", kind: "string", value: part2.parts[0].name };
      }
      if (isKw("OVER")) error("Window functions are not supported yet");
      if (isKw("WITHIN")) error("WITHIN GROUP aggregates are not supported yet");
      return node2;
    };
    const caseExpression = () => {
      p++;
      const node2 = { type: "case", whens: [] };
      if (!isKw("WHEN")) node2.operand = expr();
      while (isKw("WHEN")) {
        p++;
        const when = expr();
        takeKw("THEN");
        node2.whens.push({ when, then: expr() });
      }
      if (!node2.whens.length) error("CASE needs at least one WHEN");
      if (isKw("ELSE")) {
        p++;
        node2.else = expr();
      }
      takeKw("END");
      return node2;
    };
    const node = expr();
    if (p !== tokens.length) error("Unexpected text");
    return node;
  }
  function children(node) {
    switch (node.type) {
      case "binary":
        return [node.left, node.right];
      case "unary":
      case "isnull":
      case "cast":
        return [node.arg];
      case "call":
        return node.args;
      case "case":
        return [node.operand, ...node.whens.flatMap((w) => [w.when, w.then]), node.else].filter(Boolean);
      case "in":
        return [node.arg, ...node.list];
      case "between":
        return [node.arg, node.low, node.high];
      case "like":
        return [node.arg, node.pattern];
      case "distinctfrom":
        return [node.left, node.right];
      case "field":
        return [node.expr];
      default:
        return [];
    }
  }

  // src/types.js
  var NUMERIC = ["Integer", "Decimal", "Float"];
  var widest = (types) => {
    const known = types.filter(Boolean);
    if (!known.length) return void 0;
    if (known.every((t) => NUMERIC.includes(t))) return known.includes("Float") ? "Float" : known.includes("Decimal") ? "Decimal" : "Integer";
    return known.every((t) => t === known[0]) ? known[0] : void 0;
  };
  function inferType(node) {
    switch (node.type) {
      case "lit":
        return node.kind === "number" ? node.value.includes(".") ? "Decimal" : "Integer" : node.kind === "string" ? "String" : node.kind === "boolean" ? "Boolean" : void 0;
      case "typed":
        return { DATE: "Date", TIME: "Time", TIMESTAMP: "DateTime" }[node.kind];
      case "column":
        return node.datatype;
      case "field":
        return node.datatype ?? inferType(node.expr);
      case "cast":
        return { VARCHAR: "String", BIGINT: "Integer", DECIMAL: "Decimal", DOUBLE: "Float", BOOLEAN: "Boolean", DATE: "Date", TIMESTAMP: "DateTime", TIME: "Time" }[node.to.name];
      case "unary":
        return node.op === "NOT" ? "Boolean" : inferType(node.arg);
      case "isnull":
      case "in":
      case "between":
      case "like":
      case "distinctfrom":
        return "Boolean";
      case "case":
        return widest([...node.whens.map((w) => inferType(w.then)), node.else && inferType(node.else)]);
      case "binary": {
        if (["AND", "OR", "=", "<>", "<", ">", "<=", ">="].includes(node.op)) return "Boolean";
        if (node.op === "||") return "String";
        const types = [inferType(node.left), inferType(node.right)];
        if (node.op === "/") return types.includes("Float") ? "Float" : types.every((t) => NUMERIC.includes(t)) ? "Decimal" : void 0;
        return widest(types);
      }
      case "call": {
        const rule = FUNCTIONS[node.name]?.type;
        const args = node.args.map((arg) => arg.type === "star" ? void 0 : inferType(arg));
        switch (rule) {
          case "same":
          case "first":
            return args[0];
          case "second":
            return args[1];
          case "dateadd":
            return args[2] === "Date" && ["hour", "minute", "second"].includes(node.part) ? "DateTime" : args[2];
          case "same-numeric":
            return NUMERIC.includes(args[0]) ? args[0] : void 0;
          case "average":
            return args[0] === "Float" ? "Float" : NUMERIC.includes(args[0]) ? "Decimal" : void 0;
          case "widest":
            return widest(args);
          case "widest-tail":
            return widest(args.slice(1));
          default:
            return rule;
        }
      }
      default:
        return void 0;
    }
  }
  var isNumeric = (type) => NUMERIC.includes(type);

  // src/limits.js
  var shapes = /* @__PURE__ */ new WeakMap();
  function checkComplexity(node, path, depth = 0) {
    if (depth > 128) fail("EXPRESSION_TOO_COMPLEX", "Expanded expression depth exceeds 128", path);
    let shape = shapes.get(node);
    if (!shape) {
      const parts = children(node).map((child) => checkComplexity(child, path, depth + 1));
      shape = { size: 1 + parts.reduce((sum, p) => sum + p.size, 0), depth: 1 + Math.max(0, ...parts.map((p) => p.depth)) };
      shapes.set(node, shape);
    }
    if (shape.size > 8192 || depth + shape.depth > 128) fail("EXPRESSION_TOO_COMPLEX", "Expanded expression exceeds 8192 nodes or 128 levels", path);
    return shape;
  }
  function boundedSql(sql) {
    if (sql.length > 262144) fail("QUERY_TOO_COMPLEX", "Generated SQL exceeds 262144 characters");
    return sql;
  }

  // src/validation.js
  function object(value, keys, path) {
    if (!value || typeof value !== "object" || Array.isArray(value)) {
      fail("INVALID_SHAPE", "Expected an object", path);
    }
    for (const key2 of Object.keys(value)) {
      if (!keys.includes(key2)) fail("UNSUPPORTED_FEATURE", `Unsupported property ${key2}`, path);
    }
  }
  function string(value, path, nonempty = false) {
    if (typeof value !== "string" || nonempty && !value.length) {
      fail("INVALID_SHAPE", `Expected a ${nonempty ? "nonempty " : ""}string`, path);
    }
  }
  function array(value, path) {
    if (!Array.isArray(value)) fail("INVALID_SHAPE", "Expected an array", path);
  }
  function metadata(value, path) {
    for (const key2 of ["description", "label"]) {
      if (value[key2] !== void 0) string(value[key2], `${path}.${key2}`);
    }
    const context = value.ai_context;
    if (context !== void 0 && typeof context !== "string") {
      if (!context || typeof context !== "object" || Array.isArray(context)) {
        fail("INVALID_SHAPE", "Expected an AI context string or object", `${path}.ai_context`);
      }
      if (context.instructions !== void 0) string(context.instructions, `${path}.ai_context.instructions`);
      for (const key2 of ["synonyms", "examples"]) {
        if (context[key2] !== void 0) {
          array(context[key2], `${path}.ai_context.${key2}`);
          context[key2].forEach((item, index) => string(item, `${path}.ai_context.${key2}[${index}]`));
        }
      }
    }
    if (value.custom_extensions !== void 0) {
      array(value.custom_extensions, `${path}.custom_extensions`);
      value.custom_extensions.forEach((extension, index) => {
        object(extension, ["vendor_name", "data"], `${path}.custom_extensions[${index}]`);
        string(extension.vendor_name, `${path}.custom_extensions[${index}].vendor_name`, true);
        if (extension.data !== void 0) string(extension.data, `${path}.custom_extensions[${index}].data`);
      });
    }
  }
  function deepFreeze(value) {
    if (value && typeof value === "object" && !Object.isFrozen(value)) {
      Object.values(value).forEach(deepFreeze);
      Object.freeze(value);
    }
    return value;
  }

  // src/model.js
  var OSSIE_VERSION = "0.2.0.dev0";
  var OSSIE_REVISION = "dc5138ec6dd8f8f943520f21522f20d5b1c9c619";
  var DATATYPES = ["String", "Integer", "Decimal", "Float", "Boolean", "Date", "Time", "DateTime", "DateTimeTz", "Opaque"];
  var TEMPORAL = ["Date", "Time", "DateTime", "DateTimeTz"];
  var READABLE_DIALECTS = ["OSSIE_SQL_2026", "ANSI_SQL"];
  var snapshots = /* @__PURE__ */ new WeakSet();
  var key = (name) => name.toLowerCase();
  function chooseExpression(value, path) {
    if (typeof value === "string") {
      string(value, path, true);
      return { text: value, dialect: "OSSIE_SQL_2026" };
    }
    object(value, ["dialects"], path);
    array(value.dialects, `${path}.dialects`);
    if (!value.dialects.length) fail("INVALID_SHAPE", "An expression needs at least one dialect entry", path);
    value.dialects.forEach((entry, index) => {
      object(entry, ["dialect", "expression"], `${path}.dialects[${index}]`);
      if (entry.dialect !== void 0) string(entry.dialect, `${path}.dialects[${index}].dialect`, true);
      string(entry.expression, `${path}.dialects[${index}].expression`, true);
    });
    const chosen = value.dialects.find((e) => e.dialect === "OSSIE_SQL_2026") ?? value.dialects.find((e) => e.dialect === "ANSI_SQL") ?? value.dialects.find((e) => e.dialect === void 0);
    if (!chosen) {
      const found = value.dialects.map((e) => e.dialect).join(", ");
      fail("UNSUPPORTED_DIALECT", `RooQL reads ${READABLE_DIALECTS.join(" or ")} expressions; this one only has ${found}`, path);
    }
    return { text: chosen.expression, dialect: chosen.dialect ?? "OSSIE_SQL_2026" };
  }
  function accessModifier(value, path) {
    if (value === void 0) return false;
    if (value !== "public" && value !== "private") fail("INVALID_SHAPE", "access_modifier must be public or private", path);
    return value === "private";
  }
  function keyList(value, path) {
    array(value, path);
    if (!value.length) fail("INVALID_SHAPE", "A key needs at least one column", path);
    const columns = value.map((name, index) => physicalIdentifier(name, 1, `${path}[${index}]`));
    if (new Set(columns).size !== columns.length) fail("INVALID_SHAPE", "Key columns must be distinct", path);
    return columns;
  }
  function checkExpression(node, { allowAggregates, path }) {
    checkComplexity(node, path);
    let aggregates = 0;
    const visit = (current, inAggregate) => {
      if (current.type === "call") {
        const spec = functionSpec(current, path);
        if (spec.kind === "aggregate") {
          if (!allowAggregates) fail("AGGREGATE_IN_FIELD", `${current.name} is an aggregate; field expressions are row-level. Define it as a metric instead`, path);
          if (inAggregate) fail("NESTED_AGGREGATE", `${current.name} is nested inside another aggregate, which Ossie defers`, path);
          aggregates++;
          current.args.forEach((arg) => visit(arg, true));
          const type = inferType(current.args[0]);
          if (spec.numeric && type && !isNumeric(type)) fail("INVALID_METRIC_TYPE", `${current.name} needs a numeric argument`, path);
          return;
        }
      }
      if (allowAggregates && !inAggregate && (current.type === "field" || current.type === "column")) {
        fail("UNAGGREGATED_FIELD", `${current.name ?? "A column"} is used outside an aggregate; wrap it in SUM, COUNT, MIN, MAX or similar`, path);
      }
      if (current.type === "field") return;
      for (const child of children(current)) visit(child, inAggregate);
      if (current.type === "call" && current.name === "DATEADD") {
        const amountType = inferType(current.args[1]);
        if (amountType && amountType !== "Integer") fail("UNSUPPORTED_EXPRESSION", "DATEADD needs an integer amount", path);
        current.dateTyped = inferType(current) === "Date";
      }
    };
    visit(node, false);
    return aggregates;
  }
  function assertModelSnapshot(model) {
    if (!model || !snapshots.has(model)) fail("INVALID_MODEL", "Load a validated snapshot with loadOssieModel first");
  }
  async function loadOssieModel(jsonText) {
    if (typeof jsonText !== "string") fail("INVALID_MODEL", "Expected Ossie JSON text");
    if (!jsonText.isWellFormed()) fail("INVALID_MODEL", "JSON text contains an unpaired surrogate");
    let document;
    try {
      document = JSON.parse(jsonText);
    } catch {
      fail("INVALID_JSON", "Could not parse Ossie JSON text");
    }
    object(document, ["version", "name", "description", "ai_context", "datasets", "relationships", "metrics", "custom_extensions"], "model");
    if (document.version !== OSSIE_VERSION) fail("UNSUPPORTED_VERSION", `Expected Ossie version ${OSSIE_VERSION}`, "model.version");
    string(document.name, "model.name", true);
    metadata(document, "model");
    array(document.datasets, "model.datasets");
    if (!document.datasets.length) fail("INVALID_SHAPE", "A model needs at least one dataset", "model.datasets");
    const relationshipDocs = document.relationships === void 0 ? [] : document.relationships;
    array(relationshipDocs, "model.relationships");
    const metricDocs = document.metrics === void 0 ? [] : document.metrics;
    array(metricDocs, "model.metrics");
    const globals = /* @__PURE__ */ new Map();
    const claim = (name, kind, path) => {
      semanticName(name, `${path}.name`);
      const k = key(name);
      if (globals.has(k)) fail(globals.get(k) === kind ? "DUPLICATE_NAME" : "NAME_COLLISION", `${kind} ${name} reuses the name of a ${globals.get(k)}`, path);
      globals.set(k, kind);
    };
    const datasets = /* @__PURE__ */ new Map();
    document.datasets.forEach((ds, index) => {
      const path = `model.datasets[${index}]`;
      object(ds, ["name", "source", "primary_key", "unique_keys", "description", "ai_context", "fields", "custom_extensions"], path);
      claim(ds?.name, "dataset", path);
      metadata(ds, path);
      string(ds.source, `${path}.source`, true);
      if (/^\s*\(?\s*(select|with)\b/i.test(ds.source)) {
        fail("UNSUPPORTED_FEATURE", "Dataset sources must be a table or view name; SQL query sources are not supported yet", `${path}.source`);
      }
      const fieldDocs = ds.fields === void 0 ? [] : ds.fields;
      array(fieldDocs, `${path}.fields`);
      const fieldNames = /* @__PURE__ */ new Set();
      fieldDocs.forEach((field, fi) => {
        const fpath = `${path}.fields[${fi}]`;
        object(field, ["name", "expression", "dimension", "label", "description", "datatype", "ai_context", "custom_extensions", "access_modifier"], fpath);
        semanticName(field.name, `${fpath}.name`);
        if (fieldNames.has(key(field.name))) fail("DUPLICATE_NAME", `Duplicate field ${field.name}`, `${path}.fields`);
        fieldNames.add(key(field.name));
      });
      const source = ds.source.replace(/"(?:[^"]|"")*"|`([^`]*)`/g, (match, part2) => part2 === void 0 ? match : quoteIdentifier(part2));
      datasets.set(key(ds.name), {
        doc: ds,
        path,
        key: key(ds.name),
        name: ds.name,
        // Backtick quoting (MySQL, BigQuery) names a table just as unambiguously as double quotes.
        source: physicalIdentifier(source, 3, `${path}.source`),
        sourceBigquery: physicalIdentifier(source, 3, `${path}.source`, false),
        primaryKey: ds.primary_key === void 0 ? void 0 : keyList(ds.primary_key, `${path}.primary_key`),
        uniqueKeys: (ds.unique_keys === void 0 ? [] : (array(ds.unique_keys, `${path}.unique_keys`), ds.unique_keys)).map((uk, ui) => keyList(uk, `${path}.unique_keys[${ui}]`)),
        fieldDocs: new Map(fieldDocs.map((field, fi) => [key(field.name), { doc: field, path: `${path}.fields[${fi}]` }])),
        fields: /* @__PURE__ */ new Map()
      });
    });
    relationshipDocs.forEach((rel, index) => claim(rel?.name, "relationship", `model.relationships[${index}]`));
    metricDocs.forEach((metric, index) => claim(metric?.name, "metric", `model.metrics[${index}]`));
    const resolving = /* @__PURE__ */ new Set();
    const resolveField = (dsKey, fieldKey, path) => {
      const ds = datasets.get(dsKey);
      if (ds.fields.has(fieldKey)) return ds.fields.get(fieldKey);
      const entry = ds.fieldDocs.get(fieldKey);
      if (!entry) fail("UNKNOWN_FIELD", `Unknown field ${ds.name}.${fieldKey}`, path);
      const id = `${dsKey}.${fieldKey}`;
      if (resolving.has(id)) fail("CYCLIC_REFERENCE", `Field ${ds.name}.${entry.doc.name} refers to itself`, entry.path);
      if (resolving.size >= 64) fail("EXPRESSION_TOO_COMPLEX", "Reference depth exceeds 64", path);
      resolving.add(id);
      const { doc, path: fpath } = entry;
      metadata(doc, fpath);
      if (doc.datatype !== void 0 && !DATATYPES.includes(doc.datatype)) fail("UNSUPPORTED_DATATYPE", `datatype must be one of ${DATATYPES.join(", ")}`, `${fpath}.datatype`);
      let explicitTime;
      if (doc.dimension !== void 0) {
        object(doc.dimension, ["is_time"], `${fpath}.dimension`);
        if (doc.dimension.is_time !== void 0 && typeof doc.dimension.is_time !== "boolean") fail("INVALID_SHAPE", "is_time must be true or false", `${fpath}.dimension.is_time`);
        explicitTime = doc.dimension.is_time;
      }
      const { text, dialect } = chooseExpression(doc.expression, `${fpath}.expression`);
      const ast = parseExpression(text, `${fpath}.expression`);
      checkComplexity(ast, `${fpath}.expression`);
      const resolved = resolveRefs(ast, (ref) => {
        if (ref.parts.length === 1) {
          const part2 = ref.parts[0];
          const column = quoteIdentifier(part2.quoted ? part2.name : part2.name.toUpperCase());
          const sibling = ds.fieldDocs.get(key(part2.name))?.doc;
          const siblingText = sibling && (typeof sibling.expression === "string" ? sibling.expression : sibling.expression?.dialects?.find((e) => READABLE_DIALECTS.includes(e.dialect ?? "OSSIE_SQL_2026"))?.expression);
          const datatype2 = siblingText?.trim().toLowerCase() === part2.name.toLowerCase() && DATATYPES.includes(sibling.datatype) ? sibling.datatype : void 0;
          return { type: "column", dataset: dsKey, column, name: `${ds.name}.${doc.name}`, ...datatype2 ? { datatype: datatype2 } : {} };
        }
        if (ref.parts.length === 2) {
          const target = key(ref.parts[0].name);
          if (target === dsKey) return resolveField(dsKey, key(ref.parts[1].name), `${fpath}.expression`);
          if (datasets.has(target)) fail("UNSUPPORTED_FEATURE", `Field expressions can only use their own dataset's columns; move logic that uses ${ref.parts[0].name} into a metric`, `${fpath}.expression`);
          fail("UNKNOWN_DATASET", `Unknown dataset ${ref.parts[0].name}`, `${fpath}.expression`);
        }
        fail("INVALID_IDENTIFIER", "Field expressions use column names or dataset.field references", `${fpath}.expression`);
      });
      checkExpression(resolved, { allowAggregates: false, path: `${fpath}.expression` });
      const datatype = doc.datatype ?? inferType(resolved);
      const plain = resolved.type === "column" ? resolved.column : void 0;
      const field = {
        type: "field",
        dataset: dsKey,
        name: `${ds.name}.${doc.name}`,
        datatype,
        isTime: explicitTime ?? TEMPORAL.includes(datatype),
        private: accessModifier(doc.access_modifier, `${fpath}.access_modifier`),
        description: doc.description,
        dialect,
        expression: text,
        expr: resolved,
        ...plain ? { column: plain } : {}
      };
      resolving.delete(id);
      ds.fields.set(fieldKey, field);
      return field;
    };
    for (const ds of datasets.values()) for (const fieldKey of ds.fieldDocs.keys()) resolveField(ds.key, fieldKey);
    const relationships = relationshipDocs.map((rel, index) => {
      const path = `model.relationships[${index}]`;
      object(rel, ["name", "from", "to", "from_columns", "to_columns", "description", "ai_context", "custom_extensions"], path);
      metadata(rel, path);
      for (const end of ["from", "to"]) {
        string(rel[end], `${path}.${end}`, true);
        if (!datasets.has(key(rel[end]))) fail("UNKNOWN_DATASET", `Unknown dataset ${rel[end]}`, `${path}.${end}`);
      }
      const from = datasets.get(key(rel.from));
      const to = datasets.get(key(rel.to));
      if (from === to) fail("UNSUPPORTED_FEATURE", "Self-relationships are not supported yet", path);
      const fromColumns = keyList(rel.from_columns, `${path}.from_columns`);
      const toColumns = keyList(rel.to_columns, `${path}.to_columns`);
      if (fromColumns.length !== toColumns.length) fail("INVALID_SHAPE", "from_columns and to_columns need the same number of columns", path);
      const side = (ds, column) => ({ type: "column", dataset: ds.key, column });
      const pairs = fromColumns.map((column, i) => [side(from, column), side(to, toColumns[i])]);
      const sameSet = (a, b) => b && a.length === b.length && a.every((c) => b.includes(c));
      const oneToOne = sameSet(fromColumns, from.primaryKey) || from.uniqueKeys.some((uk) => sameSet(fromColumns, uk));
      return { name: rel.name, from: from.key, to: to.key, pairs, oneToOne };
    });
    const metricEntries = new Map(metricDocs.map((doc, index) => [key(doc.name), { doc, path: `model.metrics[${index}]` }]));
    const metrics = /* @__PURE__ */ new Map();
    const resolveMetric = (metricKey, path) => {
      if (metrics.has(metricKey)) return metrics.get(metricKey);
      const entry = metricEntries.get(metricKey);
      if (!entry) fail("UNKNOWN_METRIC", `Unknown metric ${metricKey}`, path);
      if (resolving.has(`metric:${metricKey}`)) fail("CYCLIC_REFERENCE", `Metric ${entry.doc.name} refers to itself`, entry.path);
      if (resolving.size >= 64) fail("EXPRESSION_TOO_COMPLEX", "Reference depth exceeds 64", path);
      resolving.add(`metric:${metricKey}`);
      const { doc, path: mpath } = entry;
      object(doc, ["name", "expression", "description", "datatype", "ai_context", "custom_extensions", "access_modifier"], mpath);
      metadata(doc, mpath);
      if (doc.datatype !== void 0 && !DATATYPES.includes(doc.datatype)) fail("UNSUPPORTED_DATATYPE", `datatype must be one of ${DATATYPES.join(", ")}`, `${mpath}.datatype`);
      const { text, dialect } = chooseExpression(doc.expression, `${mpath}.expression`);
      const epath = `${mpath}.expression`;
      const ast = parseExpression(text, epath);
      checkComplexity(ast, epath);
      const resolved = resolveRefs(ast, (ref) => {
        if (ref.parts.length === 1) {
          const name = key(ref.parts[0].name);
          if (metricEntries.has(name)) return resolveMetric(name, epath).expr;
          fail("UNKNOWN_METRIC", `Unknown metric ${ref.parts[0].name}; refer to fields as dataset.field`, epath);
        }
        if (ref.parts.length === 2) {
          const dsKey = key(ref.parts[0].name);
          if (!datasets.has(dsKey)) fail("UNKNOWN_DATASET", `Unknown dataset ${ref.parts[0].name}`, epath);
          const ds = datasets.get(dsKey);
          const field = ds.fields.get(key(ref.parts[1].name));
          if (field) return field;
          const part2 = ref.parts[1];
          return { type: "column", dataset: dsKey, column: quoteIdentifier(part2.quoted ? part2.name : part2.name.toUpperCase()), name: `${ds.name}.${part2.name}` };
        }
        fail("INVALID_IDENTIFIER", "Metric expressions use metric names or dataset.field references", epath);
      });
      if (checkExpression(resolved, { allowAggregates: true, path: epath }) === 0) {
        fail("METRIC_WITHOUT_AGGREGATE", "A metric needs at least one aggregate such as SUM or COUNT", epath);
      }
      const metric = {
        name: doc.name,
        datatype: doc.datatype ?? inferType(resolved),
        description: doc.description,
        private: accessModifier(doc.access_modifier, `${mpath}.access_modifier`),
        dialect,
        expression: text,
        expr: resolved
      };
      resolving.delete(`metric:${metricKey}`);
      metrics.set(metricKey, metric);
      return metric;
    };
    for (const metricKey of metricEntries.keys()) resolveMetric(metricKey);
    const digest = await globalThis.crypto.subtle.digest("SHA-256", new TextEncoder().encode(jsonText));
    const modelHash = Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
    const snapshot = deepFreeze({
      name: document.name,
      version: OSSIE_VERSION,
      specRevision: OSSIE_REVISION,
      modelHash,
      datasets: [...datasets.values()].map((ds) => ({
        name: ds.name,
        key: ds.key,
        source: ds.source,
        sourceBigquery: ds.sourceBigquery,
        primaryKey: ds.primaryKey,
        uniqueKeys: ds.uniqueKeys,
        description: ds.doc.description,
        fields: [...ds.fields.values()]
      })),
      relationships,
      metrics: [...metrics.values()]
    });
    snapshots.add(snapshot);
    return snapshot;
  }
  function resolveRefs(node, resolver) {
    if (node.type === "ref") return resolver(node);
    const copy = { ...node };
    for (const [prop, value] of Object.entries(node)) {
      if (Array.isArray(value)) {
        copy[prop] = value.map((item) => item && typeof item === "object" && "when" in item ? { when: resolveRefs(item.when, resolver), then: resolveRefs(item.then, resolver) } : item && typeof item === "object" && item.type ? resolveRefs(item, resolver) : item);
      } else if (value && typeof value === "object" && value.type) {
        copy[prop] = resolveRefs(value, resolver);
      }
    }
    return copy;
  }

  // src/literals.js
  function stringLiteral(value, dialect, path) {
    if (!value.isWellFormed()) fail("INVALID_FILTER_VALUE", "String contains an unpaired surrogate", path);
    if (dialect === "duckdb") {
      if (value.includes("\0")) fail("INVALID_FILTER_VALUE", "String contains a NUL character", path);
      return `'${value.replaceAll("'", "''")}'`;
    }
    const escaped = value.replaceAll("\\", "\\\\").replaceAll("'", dialect === "bigquery" ? "\\'" : "''").replace(/[\u0000-\u001f\u007f-\u009f]/g, (character) => `\\u${character.charCodeAt(0).toString(16).padStart(4, "0")}`);
    return `'${escaped}'`;
  }
  var DATE = /^\d{4}-\d{2}-\d{2}$/;
  var DATETIME = /^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d{1,9})?)?$/;
  var DATETIME_TZ = /^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d{1,9})?)?(Z|[+-]\d{2}:?\d{2})$/;
  var TIME = /^\d{2}:\d{2}(:\d{2}(\.\d{1,9})?)?$/;
  var DECIMAL_TEXT = /^-?\d{1,38}(\.\d{1,38})?$/;
  function validTemporal(value, ...formats) {
    if (typeof value !== "string" || !formats.some((format) => format.test(value))) return false;
    const date = /^(\d{4})-(\d{2})-(\d{2})/.exec(value);
    if (date) {
      const [, year, month, day] = date.map(Number);
      const leap = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
      if (month < 1 || month > 12 || day < 1 || day > [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month - 1]) return false;
    }
    const time = /(?:^|[ T])(\d{2}):(\d{2})(?::(\d{2}))?/.exec(value);
    const offset = /[+-](\d{2}):?(\d{2})$/.exec(value);
    return (!time || +time[1] < 24 && +time[2] < 60 && +(time[3] ?? 0) < 60) && (!offset || +offset[1] < 24 && +offset[2] < 60);
  }
  function numberText(value, integer, path) {
    if (typeof value === "string" && !integer && DECIMAL_TEXT.test(value)) return value;
    if (typeof value !== "number" || !Number.isFinite(value)) fail("INVALID_FILTER_VALUE", "Expected a number", path);
    if (integer && !Number.isSafeInteger(value)) fail("INVALID_FILTER_VALUE", "Integer filters require a safe integer", path);
    const text = String(value);
    if (!DECIMAL_TEXT.test(text)) fail("INVALID_FILTER_VALUE", 'Pass very large or very small decimals as a string, like "0.000001"', path);
    return text;
  }
  function valueLiteral(value, datatype, dialect, path) {
    const type = datatype ?? (typeof value === "string" ? "String" : typeof value === "number" ? "Decimal" : typeof value === "boolean" ? "Boolean" : void 0);
    if (dialect === "bigquery" && ["Date", "Time", "DateTime", "DateTimeTz"].includes(type) && typeof value === "string") {
      if (/\.\d{7}/.test(value) || value.startsWith("0000-")) fail("INVALID_FILTER_VALUE", "BigQuery temporal values need years 1\u20139999 and at most six fractional digits", path);
      if (type === "DateTime" && DATE.test(value)) value += " 00:00:00";
      value = value.replace(/((?:^|[ T])\d{2}:\d{2})(?=$|Z|[+-])/, "$1:00").replace(/([+-]\d{2})(\d{2})$/, "$1:$2");
    }
    switch (type) {
      case "String":
        if (typeof value !== "string") fail("INVALID_FILTER_VALUE", "Expected a string value", path);
        return stringLiteral(value, dialect, path);
      case "Integer":
        return numberText(value, true, path);
      case "Decimal":
      case "Float": {
        const text = numberText(value, false, path);
        return dialect === "bigquery" && type === "Decimal" ? `BIGNUMERIC '${text}'` : text;
      }
      case "Boolean":
        if (typeof value !== "boolean") fail("INVALID_FILTER_VALUE", "Expected true or false", path);
        return value ? "TRUE" : "FALSE";
      case "Date":
        if (!validTemporal(value, DATE)) fail("INVALID_FILTER_VALUE", 'Expected a valid date like "2026-01-31"', path);
        return `DATE '${value}'`;
      case "DateTime":
        if (!validTemporal(value, DATE, DATETIME)) fail("INVALID_FILTER_VALUE", 'Expected a valid date-time like "2026-01-31 13:45:00"', path);
        return `${dialect === "bigquery" ? "DATETIME" : dialect === "duckdb" ? "TIMESTAMP" : "TIMESTAMP_NTZ"} '${value.replace("T", " ")}'`;
      case "DateTimeTz":
        if (!validTemporal(value, DATETIME_TZ)) fail("INVALID_FILTER_VALUE", 'Expected a valid date-time with an offset like "2026-01-31T13:45:00Z"', path);
        return `CAST('${value}' AS ${dialect === "bigquery" ? "TIMESTAMP" : dialect === "duckdb" ? "TIMESTAMPTZ" : "TIMESTAMP_TZ"})`;
      case "Time":
        if (!validTemporal(value, TIME)) fail("INVALID_FILTER_VALUE", 'Expected a valid time like "13:45:00"', path);
        return `TIME '${value}'`;
      default:
        fail("UNSUPPORTED_DATATYPE", `Cannot filter a ${type ?? "field of unknown type"} with this value`, path);
    }
  }

  // src/bigquery.js
  var BIGQUERY_TYPES = { VARCHAR: "STRING", BIGINT: "INT64", DOUBLE: "FLOAT64", BOOLEAN: "BOOL", DATE: "DATE", TIME: "TIME", TIMESTAMP: "DATETIME" };
  var CLOCK = ["hour", "minute", "second"];
  var temporal = (node) => {
    const type = inferType(node);
    if (!["Date", "DateTime", "DateTimeTz", "Time"].includes(type)) fail("UNSUPPORTED_EXPRESSION", "BigQuery date functions need a known temporal datatype");
    return type;
  };
  var part = (node) => node.part === "week" ? "WEEK(MONDAY)" : node.part.toUpperCase();
  var unsupported = (name) => fail("UNSUPPORTED_EXPRESSION", `${name} has no equivalent in the BigQuery expression subset`);
  function bigqueryLike(subject, pattern, negated, insensitive = false) {
    const text = (sql) => insensitive ? `LOWER(CAST(${sql} AS STRING))` : `CAST(${sql} AS STRING)`;
    return `(CASE WHEN ${subject} IS NULL OR ${pattern} IS NULL THEN NULL ELSE ${text(subject)} ${negated ? "NOT " : ""}LIKE ${text(pattern)} END)`;
  }
  function bigqueryCall(node, args) {
    const [a, b, c] = args;
    switch (node.name) {
      case "MEDIAN":
        return unsupported("Exact MEDIAN");
      case "CONCAT":
        return `CONCAT(${args.map((arg) => `CAST(${arg} AS STRING)`).join(", ")})`;
      case "CONTAINS":
        return `(STRPOS(${a}, ${b}) > 0)`;
      case "STARTSWITH":
        return `STARTS_WITH(${a}, ${b})`;
      case "ENDSWITH":
        return `ENDS_WITH(${a}, ${b})`;
      case "SPLIT_PART": {
        const delimiter = node.args[1];
        if (delimiter.type !== "lit" || delimiter.kind !== "string") return unsupported("SPLIT_PART with a nonliteral delimiter");
        const list = delimiter.value === "" ? `[${a}]` : `SPLIT(${a}, ${b})`;
        return `CASE WHEN ${a} IS NULL OR ${c} IS NULL THEN NULL ELSE COALESCE(IF(${c} < 0, ARRAY_REVERSE(${list}), ${list})[SAFE_ORDINAL(IF(${c} = 0, 1, ABS(${c})))], '') END`;
      }
      case "YEAR":
      case "QUARTER":
      case "MONTH":
      case "WEEK":
      case "DAY":
      case "DAYOFYEAR":
      case "HOUR":
      case "MINUTE":
      case "SECOND": {
        const type = temporal(node.args[0]);
        const clock = CLOCK.includes(node.name.toLowerCase());
        if (type === "Time" && !clock) return unsupported("Calendar extraction from TIME");
        return `EXTRACT(${node.name === "WEEK" ? "ISOWEEK" : node.name} FROM ${type === "Date" && clock ? `CAST(${a} AS DATETIME)` : a})`;
      }
      case "DATE_TRUNC": {
        const type = temporal(node.args[1]);
        if (type === "Time" && !CLOCK.includes(node.part)) return unsupported("Calendar truncation of TIME");
        if (type === "Date" && CLOCK.includes(node.part)) return `CAST(DATETIME_TRUNC(CAST(${b} AS DATETIME), ${part(node)}) AS DATE)`;
        const fn = { Date: "DATE", DateTime: "DATETIME", DateTimeTz: "TIMESTAMP", Time: "TIME" }[type];
        return `${fn}_TRUNC(${b}, ${part(node)})`;
      }
      case "DATEADD": {
        const type = temporal(node.args[2]);
        const clock = CLOCK.includes(node.part);
        if (type === "Time" && !clock) return unsupported("Calendar addition to TIME");
        if (type === "DateTimeTz") return `TIMESTAMP(DATETIME_ADD(CAST(${c} AS DATETIME), INTERVAL ${b} ${node.part.toUpperCase()}))`;
        const fn = type === "Time" ? "TIME" : type === "Date" && !clock ? "DATE" : "DATETIME";
        const date = type === "Date" && clock ? `CAST(${c} AS DATETIME)` : c;
        return `${fn}_ADD(${date}, INTERVAL ${b} ${node.part.toUpperCase()})`;
      }
      case "DATEDIFF": {
        const start = temporal(node.args[1]);
        const end = temporal(node.args[2]);
        if (start === "Time" || end === "Time") {
          if (start !== end || !CLOCK.includes(node.part)) return unsupported("Mixed or calendar TIME differences");
          return `TIME_DIFF(${c}, ${b}, ${part(node)})`;
        }
        return `DATETIME_DIFF(CAST(${c} AS DATETIME), CAST(${b} AS DATETIME), ${part(node)})`;
      }
      default:
        return void 0;
    }
  }

  // src/predicates.js
  var COMPARISONS = { eq: "=", ne: "<>", gt: ">", gte: ">=", lt: "<", lte: "<=" };
  var FILTER_OPERATORS = [...Object.keys(COMPARISONS), "in", "notIn", "between", "notBetween", "like", "notLike", "isNull", "isNotNull"];
  var HAVING_OPERATORS = [...Object.keys(COMPARISONS), "between", "isNull", "isNotNull"];
  function predicate(subject, datatype, { operator, value, values }, path, operators, dialect) {
    if (["like", "notLike"].includes(operator)) {
      if (datatype && datatype !== "String") fail("UNSUPPORTED_OPERATOR", "LIKE needs a string field", path);
      datatype = "String";
    }
    if (!operators.includes(operator)) fail("UNSUPPORTED_OPERATOR", `Supported operators are ${operators.join(", ")}`, `${path}.operator`);
    const literal = (v, p) => {
      if (v === null || v === void 0) fail("INVALID_FILTER_VALUE", "Use isNull or isNotNull to test for null", p);
      return valueLiteral(v, datatype, dialect, p);
    };
    if (operator === "isNull" || operator === "isNotNull") {
      if (value !== void 0 || values !== void 0) fail("INVALID_FILTER_VALUE", "Null checks do not take a value", path);
      return `${subject} IS ${operator === "isNotNull" ? "NOT " : ""}NULL`;
    }
    if (["in", "notIn", "between", "notBetween"].includes(operator)) {
      if (value !== void 0) fail("INVALID_FILTER_VALUE", `${operator} takes values, a list`, path);
      array(values, `${path}.values`);
      if (operator.endsWith("etween")) {
        if (values.length !== 2) fail("INVALID_FILTER_VALUE", "between takes exactly two values: [low, high]", `${path}.values`);
        return `${subject} ${operator === "notBetween" ? "NOT " : ""}BETWEEN ${literal(values[0], `${path}.values[0]`)} AND ${literal(values[1], `${path}.values[1]`)}`;
      }
      if (!values.length || values.length > 1e3) fail("INVALID_FILTER_VALUE", `${operator} takes 1 to 1000 values`, `${path}.values`);
      return `${subject} ${operator === "notIn" ? "NOT " : ""}IN (${values.map((v, j) => literal(v, `${path}.values[${j}]`)).join(", ")})`;
    }
    if (values !== void 0) fail("INVALID_FILTER_VALUE", `${operator} takes a single value`, path);
    if (operator === "like" || operator === "notLike") {
      if (typeof value !== "string") fail("INVALID_FILTER_VALUE", "like takes a string pattern", `${path}.value`);
      if (dialect === "bigquery") return bigqueryLike(subject, literal(value, `${path}.value`), operator === "notLike");
      return `${subject} ${operator === "notLike" ? "NOT " : ""}LIKE ${literal(value, `${path}.value`)}`;
    }
    return `${subject} ${COMPARISONS[operator]} ${literal(value, `${path}.value`)}`;
  }

  // src/planner.js
  function datasetsIn(nodes) {
    const found = /* @__PURE__ */ new Set();
    const visit = (node) => {
      if (node.type === "column" || node.type === "field") found.add(node.dataset);
      if (node.type === "column") return;
      for (const child of children(node)) visit(child);
    };
    nodes.forEach(visit);
    return found;
  }
  function aggregatesIn(node, list = []) {
    if (node.type === "call" && FUNCTIONS[node.name]?.kind === "aggregate") {
      const spec = FUNCTIONS[node.name];
      list.push({
        name: node.name,
        datasets: datasetsIn(node.args.filter((arg) => arg.type !== "star")),
        // Repeating input rows cannot change these results.
        duplicateSafe: Boolean(spec.duplicateSafe || node.name === "COUNT" && node.distinct)
      });
      return list;
    }
    for (const child of children(node)) aggregatesIn(child, list);
    return list;
  }
  function edgesOf(model) {
    const edges = new Map(model.datasets.map((ds) => [ds.key, []]));
    for (const rel of model.relationships) {
      edges.get(rel.from).push({ to: rel.to, rel, pairs: rel.pairs, sameGrain: rel.oneToOne });
      if (rel.oneToOne) edges.get(rel.to).push({ to: rel.from, rel, pairs: rel.pairs.map(([a, b]) => [b, a]), sameGrain: true });
    }
    return edges;
  }
  function pathsBetween(edges, start, target) {
    const found = [];
    const visit = (node, seen, path) => {
      if (found.length > 1) return;
      if (node === target) {
        found.push(path);
        return;
      }
      for (const edge of edges.get(node)) {
        if (seen.has(edge.to) || path.length > 12) continue;
        seen.add(edge.to);
        visit(edge.to, seen, [...path, edge]);
        seen.delete(edge.to);
      }
    };
    visit(start, /* @__PURE__ */ new Set([start]), []);
    return found;
  }
  var names = (model, keys) => [...keys].map((k) => model.datasets.find((ds) => ds.key === k).name).join(", ");
  function planJoins(model, { metrics, others }) {
    const touched = datasetsIn([...metrics.map((m) => m.expr), ...others]);
    const aggregates = metrics.flatMap((metric) => aggregatesIn(metric.expr).map((agg) => ({ ...agg, metric: metric.name })));
    const order = model.datasets.map((ds) => ds.key);
    if (touched.size === 0) {
      if (model.datasets.length === 1) return { anchor: order[0], joins: [], alias: () => '"t0"' };
      fail("AMBIGUOUS_ANCHOR", "This query does not say which dataset to count; use COUNT(dataset.field) instead of COUNT(*), or add a metric from the fact table");
    }
    if (model.datasets.length > 1 && aggregates.length && aggregates.every((agg) => !agg.datasets.size)) {
      fail("AMBIGUOUS_ANCHOR", `${aggregates[0].metric} counts rows with COUNT(*), but this model has several datasets; use COUNT(dataset.key) or add a metric from the fact table`);
    }
    const edges = edgesOf(model);
    const aggregateHomes = new Set(aggregates.flatMap((agg) => [...agg.datasets]));
    const candidates = [...order.filter((k) => aggregateHomes.has(k)), ...order.filter((k) => touched.has(k) && !aggregateHomes.has(k))];
    const failures = [];
    const valid = [];
    for (const anchor of candidates) {
      if (valid.length && aggregateHomes.has(valid[0].anchor) && !aggregateHomes.has(anchor)) break;
      const routes = /* @__PURE__ */ new Map();
      let problem;
      for (const target of touched) {
        if (target === anchor) continue;
        const paths = pathsBetween(edges, anchor, target);
        if (!paths.length) {
          problem = { kind: "unreachable", target };
          break;
        }
        if (paths.length > 1) {
          problem = { kind: "ambiguous", target, paths };
          break;
        }
        routes.set(target, paths[0]);
      }
      if (!problem) {
        for (const agg of aggregates) {
          if (!agg.datasets.size || agg.datasets.has(anchor) || agg.duplicateSafe) continue;
          if ([...agg.datasets].every((ds) => routes.get(ds).every((edge) => edge.sameGrain))) continue;
          problem = { kind: "fanout", agg };
          break;
        }
      }
      if (!problem) valid.push({ anchor, routes });
      else failures.push({ anchor, ...problem });
    }
    if (valid.length > 1) fail("AMBIGUOUS_ANCHOR", `Several datasets can anchor these metrics (${names(model, valid.map((p) => p.anchor))}); one-to-one keys do not guarantee matching populations. Run one query per dataset`);
    if (valid.length) return buildPlan(model, valid[0].anchor, valid[0].routes);
    const fanout = failures.find((f) => f.kind === "fanout");
    const ambiguous = failures.find((f) => f.kind === "ambiguous");
    if (fanout) {
      const { agg, anchor } = fanout;
      fail("FANOUT_UNSAFE", `${agg.metric} uses ${agg.name} over ${names(model, agg.datasets)}, which repeats once per ${names(model, [anchor])} row when joined, so it would be double-counted. Group by ${names(model, agg.datasets)} fields only, or use COUNT(DISTINCT), MIN or MAX`);
    }
    if (ambiguous) {
      const via = ambiguous.paths.map((path) => path.map((edge) => edge.rel.name).join(" \u2192 ")).join(" or ");
      fail("AMBIGUOUS_PATH", `${names(model, [ambiguous.anchor])} reaches ${names(model, [ambiguous.target])} in more than one way (${via}); RooQL won't guess which one you mean`);
    }
    if (aggregateHomes.size > 1 && candidates.filter((c) => aggregateHomes.has(c)).every((c) => failures.find((f) => f.anchor === c)?.kind === "unreachable")) {
      fail("MULTI_FACT_UNSUPPORTED", `The metrics aggregate ${names(model, aggregateHomes)}, which are separate facts with no many-to-one path between them; querying them together is not supported yet. Run one query per fact and combine the results`);
    }
    fail("NO_PATH", `No many-to-one join path connects ${names(model, touched)} from a single dataset`);
  }
  function buildPlan(model, anchor, routes) {
    const aliases = /* @__PURE__ */ new Map([[anchor, '"t0"']]);
    const joins = [];
    const targets = model.datasets.map((ds) => ds.key).filter((k) => routes.has(k));
    for (const target of targets) {
      for (const edge of routes.get(target)) {
        if (!aliases.has(edge.to)) {
          aliases.set(edge.to, `"t${aliases.size}"`);
          const ds = model.datasets.find((d) => d.key === edge.to);
          joins.push({ dataset: edge.to, source: ds.source, sourceBigquery: ds.sourceBigquery, on: edge.pairs });
        }
      }
    }
    return {
      anchor,
      joins,
      alias: (key2) => {
        if (!aliases.has(key2)) throw new Error(`No join planned for dataset ${key2}`);
        return aliases.get(key2);
      }
    };
  }

  // src/render.js
  var TYPED = { snowflake: { DATE: "DATE", TIME: "TIME", TIMESTAMP: "TIMESTAMP_NTZ" }, duckdb: { DATE: "DATE", TIME: "TIME", TIMESTAMP: "TIMESTAMP" }, bigquery: BIGQUERY_TYPES };
  function renderExpression(node, dialect, alias, cache = /* @__PURE__ */ new Map()) {
    if (!cache.has(node)) cache.set(node, boundedSql(renderNode(node, dialect, (child) => renderExpression(child, dialect, alias, cache), alias)));
    return cache.get(node);
  }
  function renderNode(node, dialect, r, alias) {
    switch (node.type) {
      case "column":
        return `${outputIdentifier(alias(node.dataset), dialect)}.${outputIdentifier(node.column, dialect)}`;
      case "field":
        return r(node.expr);
      case "lit":
        if (node.kind === "null") return "NULL";
        if (node.kind === "boolean") return node.value ? "TRUE" : "FALSE";
        if (node.kind === "string") return stringLiteral(node.value, dialect);
        return node.value;
      case "typed":
        return dialect === "bigquery" ? valueLiteral(node.value, inferType(node), dialect) : `${TYPED[dialect][node.kind]} ${stringLiteral(node.value, dialect)}`;
      case "star":
        return "*";
      case "unary":
        return node.op === "NOT" ? `(NOT ${r(node.arg)})` : `(-${r(node.arg)})`;
      case "binary":
        return dialect === "bigquery" && node.op === "%" ? `MOD(${r(node.left)}, ${r(node.right)})` : `(${r(node.left)} ${node.op} ${r(node.right)})`;
      case "isnull":
        return `(${r(node.arg)} IS ${node.negated ? "NOT " : ""}NULL)`;
      case "distinctfrom":
        return `(${r(node.left)} IS ${node.negated ? "NOT " : ""}DISTINCT FROM ${r(node.right)})`;
      case "in":
        return `(${r(node.arg)} ${node.negated ? "NOT " : ""}IN (${node.list.map(r).join(", ")}))`;
      case "between":
        return `(${r(node.arg)} ${node.negated ? "NOT " : ""}BETWEEN ${r(node.low)} AND ${r(node.high)})`;
      case "like":
        return dialect === "bigquery" ? bigqueryLike(r(node.arg), r(node.pattern), node.negated, node.ilike) : `(${r(node.arg)} ${node.negated ? "NOT " : ""}${node.ilike ? "ILIKE" : "LIKE"} ${r(node.pattern)})`;
      case "cast": {
        if (node.try && dialect === "snowflake" && inferType(node.arg) !== "String") {
          fail("UNSUPPORTED_EXPRESSION", "Snowflake TRY_CAST needs a known string argument");
        }
        if (dialect === "bigquery" && node.to.name === "DECIMAL") fail("UNSUPPORTED_EXPRESSION", "BigQuery CAST cannot enforce DECIMAL precision and scale");
        const type = node.to.name === "DECIMAL" ? `DECIMAL(${node.to.precision}, ${node.to.scale})` : TYPED[dialect][node.to.name] ?? node.to.name;
        return `${node.try ? dialect === "bigquery" ? "SAFE_CAST" : "TRY_CAST" : "CAST"}(${r(node.arg)} AS ${type})`;
      }
      case "case": {
        const parts = ["CASE"];
        if (node.operand) parts.push(r(node.operand));
        for (const { when, then } of node.whens) parts.push(`WHEN ${r(when)} THEN ${r(then)}`);
        if (node.else) parts.push(`ELSE ${r(node.else)}`);
        parts.push("END");
        return parts.join(" ");
      }
      case "call": {
        const spec = FUNCTIONS[node.name];
        const args = node.args.map((arg) => arg === node.args[spec.datePart] && spec.datePart !== void 0 ? `'${node.part}'` : r(arg));
        if (dialect === "bigquery") {
          const rendered = bigqueryCall(node, args);
          if (rendered !== void 0) return rendered;
        }
        if (spec.render) return spec.render(args, dialect, node);
        return `${node.name}(${node.distinct ? "DISTINCT " : ""}${args.join(", ")})`;
      }
      default:
        throw new Error(`Unrenderable node ${node.type}`);
    }
  }

  // src/compiler.js
  var DIALECTS = ["snowflake", "duckdb", "bigquery"];
  var MAX_LIMIT = 1e9;
  function uniqueName(seen, name, kind, path) {
    const k = name.toLowerCase();
    if (seen.has(k)) fail("DUPLICATE_SELECTION", `Duplicate ${kind} ${name}`, path);
    seen.add(k);
  }
  function createRooql(options) {
    object(options, ["model", "dialect"], "options");
    const { model, dialect } = options;
    assertModelSnapshot(model);
    if (!DIALECTS.includes(dialect)) fail("UNSUPPORTED_DIALECT", `Supported output dialects are ${DIALECTS.join(" and ")}`, "options.dialect");
    const ident = (name) => outputIdentifier(name, dialect);
    const source = (ds) => ident(dialect === "bigquery" ? ds.sourceBigquery : ds.source);
    const fieldIndex = /* @__PURE__ */ new Map();
    for (const ds of model.datasets) for (const field of ds.fields) if (!field.private) fieldIndex.set(field.name.toLowerCase(), field);
    const metricIndex = new Map(model.metrics.filter((m) => !m.private).map((m) => [m.name.toLowerCase(), m]));
    const describe = (item) => item.description ? { description: item.description } : {};
    const catalog = deepFreeze({
      datasets: model.datasets.map((ds) => {
        const fields = ds.fields.filter((f) => !f.private).map((f) => ({ name: f.name, datatype: f.datatype ?? null, ...f.isTime ? { isTime: true } : {}, ...describe(f) }));
        return { name: ds.name, ...describe(ds), dimensions: fields, fields };
      }),
      relationships: model.relationships.map(({ name, from, to }) => ({ name, from, to })),
      metrics: model.metrics.filter((m) => !m.private).map((m) => ({ name: m.name, datatype: m.datatype ?? null, ...describe(m) }))
    });
    const lookupField = (name, path) => {
      if (typeof name !== "string") fail("INVALID_QUERY", "Expected a dataset.field name", path);
      const field = fieldIndex.get(name.toLowerCase());
      if (!field) fail("UNKNOWN_FIELD", `Unknown field ${name}`, path);
      return field;
    };
    const lookupMetric = (name, path) => {
      if (typeof name !== "string") fail("INVALID_QUERY", "Expected a metric name", path);
      const metric = metricIndex.get(name.toLowerCase());
      if (!metric) fail("UNKNOWN_METRIC", `Unknown metric ${name}`, path);
      return metric;
    };
    const compile = (query) => {
      object(query, ["metrics", "measures", "dimensions", "filters", "having", "orderBy", "limit", "valueMode"], "query");
      if (query.valueMode !== void 0 && query.valueMode !== "inline") {
        fail("UNSUPPORTED_VALUE_MODE", "Values are always escaped and inlined", "query.valueMode");
      }
      if (query.metrics !== void 0 && query.measures !== void 0) fail("INVALID_QUERY", "Use metrics or measures, not both", "query");
      const metricKey = query.measures !== void 0 ? "measures" : "metrics";
      const metricNames = query[metricKey] === void 0 ? [] : query[metricKey];
      array(metricNames, `query.${metricKey}`);
      const seenMetrics = /* @__PURE__ */ new Set();
      const metrics = metricNames.map((name, i) => {
        const metric = lookupMetric(name, `query.${metricKey}[${i}]`);
        uniqueName(seenMetrics, metric.name, "metric", `query.${metricKey}`);
        return metric;
      });
      if (!metrics.length) fail("INVALID_QUERY", "Select at least one metric", `query.${metricKey}`);
      const dimensionDocs = query.dimensions === void 0 ? [] : query.dimensions;
      array(dimensionDocs, "query.dimensions");
      const seenDimensions = /* @__PURE__ */ new Set();
      const dimensions = dimensionDocs.map((doc, i) => {
        const path = `query.dimensions[${i}]`;
        let name = doc;
        let grain;
        if (doc && typeof doc === "object") {
          object(doc, ["field", "grain"], path);
          name = doc.field;
          grain = doc.grain;
        }
        const field = lookupField(name, path);
        if (grain !== void 0) {
          if (!GRAINS.includes(grain)) fail("INVALID_QUERY", `grain must be one of ${GRAINS.join(", ")}`, `${path}.grain`);
          if (!["Date", "DateTime", "DateTimeTz"].includes(field.datatype)) {
            fail("GRAIN_NOT_APPLICABLE", `${field.name} is not a Date or DateTime field, so it cannot be grouped by ${grain}`, path);
          }
        }
        uniqueName(seenDimensions, grain ? `${field.name}:${grain}` : field.name, "dimension", "query.dimensions");
        const node = grain ? { type: "call", name: "DATE_TRUNC", part: grain, args: [{ type: "lit", kind: "string", value: grain }, field] } : field;
        return { field, grain, node };
      });
      const filterDocs = query.filters === void 0 ? [] : query.filters;
      array(filterDocs, "query.filters");
      const filters = filterDocs.map((filter, i) => {
        const path = `query.filters[${i}]`;
        object(filter, ["field", "operator", "value", "values"], path);
        return { field: lookupField(filter.field, `${path}.field`), filter, path };
      });
      const havingDocs = query.having === void 0 ? [] : query.having;
      array(havingDocs, "query.having");
      const having = havingDocs.map((condition, i) => {
        const path = `query.having[${i}]`;
        object(condition, ["metric", "operator", "value", "values"], path);
        return { metric: lookupMetric(condition.metric, `${path}.metric`), filter: condition, path };
      });
      const plan = planJoins(model, {
        metrics: [...metrics, ...having.map((h) => h.metric)],
        others: [...dimensions.map((d) => d.field), ...filters.map((f) => f.field)]
      });
      const sql = (node) => renderExpression(node, dialect, plan.alias);
      const where = filters.map(({ field, filter, path }) => {
        return predicate(sql(field), field.datatype, filter, path, FILTER_OPERATORS, dialect);
      });
      const havingSql = having.map(({ metric, filter, path }) => {
        if (metric.datatype && !isNumeric(metric.datatype)) fail("UNSUPPORTED_OPERATOR", `${metric.name} is not numeric`, path);
        return predicate(sql(metric.expr), metric.datatype ?? "Decimal", filter, path, HAVING_OPERATORS, dialect);
      });
      const columns = [
        ...dimensions.map(({ field, grain }, i) => ({ alias: `d${i}`, kind: "dimension", name: field.name, datatype: field.datatype ?? null, ...grain ? { grain } : {} })),
        ...metrics.map((metric, i) => ({ alias: `m${i}`, kind: "metric", name: metric.name, datatype: metric.datatype ?? null }))
      ];
      const orderDocs = query.orderBy === void 0 ? [] : query.orderBy;
      array(orderDocs, "query.orderBy");
      const orderBy = orderDocs.map((entry, i) => {
        const path = `query.orderBy[${i}]`;
        object(entry, ["field", "grain", "direction", "nulls"], path);
        if (entry.direction !== void 0 && typeof entry.direction !== "string") fail("INVALID_QUERY", "direction must be asc or desc", `${path}.direction`);
        const direction = (entry.direction ?? "asc").toLowerCase();
        if (direction !== "asc" && direction !== "desc") fail("INVALID_QUERY", "direction must be asc or desc", `${path}.direction`);
        if (entry.nulls !== void 0 && entry.nulls !== "first" && entry.nulls !== "last") fail("INVALID_QUERY", "nulls must be first or last", `${path}.nulls`);
        if (typeof entry.field !== "string") fail("INVALID_QUERY", "Expected the name of a selected dimension or metric", `${path}.field`);
        const name = entry.field.toLowerCase();
        const matches = columns.filter((c) => c.name.toLowerCase() === name && (entry.grain === void 0 || c.grain === entry.grain));
        if (!matches.length) fail("ORDER_BY_NOT_SELECTED", `${entry.field} is not one of the selected dimensions or metrics`, `${path}.field`);
        if (matches.length > 1) fail("INVALID_QUERY", `${entry.field} is selected at several grains; add grain to choose one`, path);
        const nulls = entry.nulls ?? (direction === "asc" ? "last" : "first");
        return `${ident(quoteIdentifier(matches[0].alias))} ${direction.toUpperCase()} NULLS ${nulls.toUpperCase()}`;
      });
      if (query.limit !== void 0 && (!Number.isSafeInteger(query.limit) || query.limit < 1 || query.limit > MAX_LIMIT)) {
        fail("INVALID_QUERY", `limit must be a whole number from 1 to ${MAX_LIMIT}`, "query.limit");
      }
      const select = [
        ...dimensions.map(({ node }, i) => `  ${sql(node)} AS ${ident(quoteIdentifier(`d${i}`))}`),
        ...metrics.map((metric, i) => `  ${sql(metric.expr)} AS ${ident(quoteIdentifier(`m${i}`))}`)
      ];
      const anchor = model.datasets.find((ds) => ds.key === plan.anchor);
      const lines = ["SELECT", select.join(",\n"), `FROM ${source(anchor)} AS ${ident(plan.alias(anchor.key))}`];
      for (const join of plan.joins) {
        const on = join.on.map(([left, right]) => `${sql(left)} = ${sql(right)}`).join(" AND ");
        lines.push(`LEFT JOIN ${source(join)} AS ${ident(plan.alias(join.dataset))} ON ${on}`);
      }
      if (where.length) lines.push(`WHERE ${where.join(" AND ")}`);
      if (dimensions.length) lines.push(`GROUP BY ${dimensions.map(({ node }) => sql(node)).join(", ")}`);
      if (havingSql.length) lines.push(`HAVING ${havingSql.join(" AND ")}`);
      if (orderBy.length) lines.push(`ORDER BY ${orderBy.join(", ")}`);
      if (query.limit !== void 0) lines.push(`LIMIT ${query.limit}`);
      return deepFreeze({ sql: boundedSql(lines.join("\n")), valueMode: "inline", dialect, modelHash: model.modelHash, specRevision: model.specRevision, columns });
    };
    return Object.freeze({ compile, catalog: () => catalog });
  }
  return __toCommonJS(index_exports);
})();