Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions canonic/compiler/dialect.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from __future__ import annotations

from abc import ABC, abstractmethod
from typing import cast

from sqlglot import exp

Expand Down Expand Up @@ -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

Expand Down
51 changes: 51 additions & 0 deletions tests/compiler/test_pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading