From e48aa53ae80254f967fcf5bcf7557492377c5d93 Mon Sep 17 00:00:00 2001 From: Mirko <2361009+mischuh@users.noreply.github.com> Date: Sat, 18 Jul 2026 19:26:46 +0200 Subject: [PATCH] fix(compiler): emit SQLite-native date arithmetic instead of INTERVAL/DATE_TRUNC sqlglot's SQLite generator doesn't transpile the compiler's neutral INTERVAL-arithmetic and DATE_TRUNC nodes into real SQLite syntax, so rolling-window filters and time-bucketed dimensions produced SQL that SQLite rejects (no INTERVAL literal, no DATE_TRUNC function). The SQLite adapter now rewrites both into DATE(col, 'modifier') calls before emission. --- canonic/compiler/dialect.py | 55 +++++++++++++++++++++++++++++++++ tests/compiler/test_pipeline.py | 51 ++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+) diff --git a/canonic/compiler/dialect.py b/canonic/compiler/dialect.py index 840249b..cd190a3 100644 --- a/canonic/compiler/dialect.py +++ b/canonic/compiler/dialect.py @@ -9,6 +9,7 @@ from __future__ import annotations from abc import ABC, abstractmethod +from typing import cast from sqlglot import exp @@ -147,12 +148,66 @@ def __init__(self) -> None: super().__init__("postgres", _POSTGRES_TYPE_MAP) +_SQLITE_TRUNC_MODIFIERS: dict[str, str] = { + "day": "start of day", + "month": "start of month", + "year": "start of year", +} + + +def _rewrite_interval_arithmetic_for_sqlite(node: exp.Expression) -> exp.Expression: + """Rewrite ``base +/- INTERVAL 'n' unit`` into SQLite's ``DATE(base, '+/-n unit')``. + + SQLite has no ``INTERVAL`` literal, so sqlglot's sqlite generator emits it verbatim + (invalid SQL). The compiler's neutral AST always represents relative-date arithmetic + this way regardless of how the filter was originally written, so this rewrite is the + single place that needs to know SQLite's actual date-modifier syntax. + """ + if not (isinstance(node, (exp.Add, exp.Sub)) and isinstance(node.expression, exp.Interval)): + return node + interval = node.expression + num = interval.this.name + unit = interval.args.get("unit") + unit_name = unit.name.lower() if unit is not None else "" + sign = "-" if isinstance(node, exp.Sub) else "+" + modifier = exp.Literal.string(f"{sign}{num} {unit_name}") + return cast("exp.Expression", exp.func("DATE", node.this, modifier)) + + +def _rewrite_date_trunc_for_sqlite(node: exp.Expression) -> exp.Expression: + """Rewrite ``DATE_TRUNC(unit, col)`` into SQLite's ``DATE(col, modifier)`` form. + + SQLite has no ``DATE_TRUNC`` function; sqlglot's sqlite generator emits it verbatim + (invalid SQL). Used both for dimension granularity bucketing and for the SQLite + ``DATE('now', 'start of ...')`` filter-modifier rewrite in ``_helpers.py``. + """ + if not isinstance(node, exp.DateTrunc): + return node + unit = node.args.get("unit") + unit_name = unit.name.lower() if unit is not None else "" + base = node.this + if unit_name == "week": + return cast( + "exp.Expression", + exp.func("DATE", base, exp.Literal.string("weekday 0"), exp.Literal.string("-6 days")), + ) + modifier = _SQLITE_TRUNC_MODIFIERS.get(unit_name) + if modifier is None: + return node + return cast("exp.Expression", exp.func("DATE", base, exp.Literal.string(modifier))) + + class SQLiteDialectAdapter(_GenericDialectAdapter): """SQLite renderer — has no ordered-set aggregate for percentile queries.""" def __init__(self) -> None: super().__init__("sqlite", _SQLITE_TYPE_MAP) + def emit(self, ast: exp.Expression, *, limit: int | None = None) -> str: + ast = ast.transform(_rewrite_interval_arithmetic_for_sqlite) + ast = ast.transform(_rewrite_date_trunc_for_sqlite) + return super().emit(ast, limit=limit) + def supports_percentile_cont(self) -> bool: return False diff --git a/tests/compiler/test_pipeline.py b/tests/compiler/test_pipeline.py index 6de1947..61311a4 100644 --- a/tests/compiler/test_pipeline.py +++ b/tests/compiler/test_pipeline.py @@ -833,6 +833,57 @@ def test_compile_postgres_date_filter_emits_postgres_interval( assert "INTERVAL '3 MONTHS'" in result.sql +def test_compile_sqlite_date_filter_emits_sqlite_date_modifier( + resolver: ContractResolver, sources: list[SemanticSource] +) -> None: + """SQLite has no INTERVAL literal; rolling-window filters must emit DATE(...) modifiers.""" + result = compile( + SemanticQuery( + metrics=["revenue"], + filters=["created_at >= DATE('now', '-3 months')"], + ), + resolver, + sources, + connection_dialects={"warehouse_pg": "sqlite"}, + ) + assert result.dialect == "sqlite" + assert "INTERVAL" not in result.sql.upper() + assert "DATE(CURRENT_DATE, '-3 months')" in result.sql + sqlglot.parse_one(result.sql, dialect="sqlite") + import sqlite3 + + con = sqlite3.connect(":memory:") + con.execute( + 'CREATE TABLE "fct_orders" (order_id text, customer_id text, status text, ' + "amount real, created_at text)" + ) + con.execute(result.sql.replace('"analytics"."fct_orders"', '"fct_orders"')) + + +def test_compile_sqlite_month_bucket_emits_sqlite_date_modifier( + resolver: ContractResolver, sources: list[SemanticSource] +) -> None: + """SQLite has no DATE_TRUNC function; granularity bucketing must use DATE(..., 'start of ...').""" + result = compile( + SemanticQuery(metrics=["revenue"], dimensions=["order_date"]), + resolver, + sources, + connection_dialects={"warehouse_pg": "sqlite"}, + ) + assert result.dialect == "sqlite" + assert "DATE_TRUNC" not in result.sql.upper() + assert 'DATE("orders"."created_at", \'start of day\')' in result.sql + sqlglot.parse_one(result.sql, dialect="sqlite") + import sqlite3 + + con = sqlite3.connect(":memory:") + con.execute( + 'CREATE TABLE "fct_orders" (order_id text, customer_id text, status text, ' + "amount real, created_at text)" + ) + con.execute(result.sql.replace('"analytics"."fct_orders"', '"fct_orders"')) + + def test_compile_empty_connection_map_falls_back_to_postgres( resolver: ContractResolver, sources: list[SemanticSource] ) -> None: