Skip to content
Open
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
36 changes: 36 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Changelog

## 4.0.0

The KQL compiler (`sqlalchemy_kusto/dialect_kql.py`) is rewritten from scratch. It
walks the SQLAlchemy expression tree with visitor methods instead of fixing compiled
SQL text with regular expressions. The output changes for every statement, hence the
major version.

Fixed:

- Saved metrics without extra arguments compiled to `dcount(["x"]None)`.
- `HAVING` was silently dropped.
- `DISTINCT` was silently dropped (filter values had duplicates).
- Series limit: the join on a grouped subquery was dropped for a physical table and
crashed for a virtual one.
- A virtual dataset starting with a `//` comment before `let` produced a syntax error.
- `=`, `'` and `"` inside string literals were rewritten by the text-based `WHERE` conversion.
- `LIKE '%x%'` compiled to `has_cs` (whole-term match); it is now `contains_cs` (substring).
- `COUNT(col)` compiled to `count(["col"])`, which Kusto rejects; it is now `countif(isnotnull(col))`.
- `ORDER BY` without a direction sorted descending (the KQL default); `asc` is now explicit.
- Group-by columns produced `extend ["X"] = ["X"]` noise; aliases now live in `summarize ... by alias = expr`.

Changed output:

- String literals are double-quoted (`"ru"`), not single-quoted.
- `text()` in `FROM` passes through as written, except a text that is only a table name: `["s"].["t"]`, `s."t"` or `s.t` becomes `database("s").["t"]` (Superset's SQL Lab table preview is built this way). A table name inside a longer KQL text is not converted.
- Table aliases are ignored (KQL has none); column references never carry a table prefix.
- Bind parameters are always rendered inline; `supports_statement_cache` is off.

Added:

- `kustokql+http://` for the Kusto emulator (`KustoKqlHttpDialect`).
- `OFFSET` via `serialize | where row_number() > n`.
- Superset's escaped colons in `text()` (`datetime(2026-01-01T00\:00\:00)`) are unescaped.
- Uppercased function names in KQL text (`TOLOWER(x)`, from Superset's sqlglot sanitizer) are lowercased.
28 changes: 27 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,33 @@ Current implementation has full support for SQL queries. But pay your attention

## KQL dialect

KQL dialect still in progress. Please, use it on your own risk for now.
The KQL dialect compiles a SQLAlchemy `Select` into a native KQL pipeline. It is built
for the statements Apache Superset generates for charts, datasets and filter values:

| SQLAlchemy | KQL |
|---|---|
| physical table, `schema` | `["Table"]`, `database("schema").["Table"]` |
| virtual dataset (`text(kql).alias("virtual_table")`) | `let virtual_table = (<kql>);` — `let` statements and `//` comments of the dataset are hoisted to the top of the script |
| `where` with `==`, `!=`, `<`, `in_`, `notin_`, `between`, `is_(None)`, `isnot(None)`, `and_`, `or_`, `not_` | `==`, `!=`, `<`, `in`, `!in`, `between (a .. b)`, `isnull()`, `isnotnull()`, `and`, `or`, `not()` |
| `like` / `ilike` by pattern shape: `%x%`, `x%`, `%x`, `x` | `contains_cs` / `contains`, `startswith_cs` / `startswith`, `endswith_cs` / `endswith`, `==` / `=~`; a `%` in the middle becomes `matches regex`; `_` is literal |
| `func.COUNT(col)`, `func.COUNT(distinct(col))`, `SUM`, `AVG`, `MIN`, `MAX` | `countif(isnotnull(col))`, `dcount(col)`, `sum`, `avg`, `min`, `max` |
| `literal_column("COUNT(*)")`, `literal_column("SUM(x)")`, saved metrics | `count()`, `sum(x)`; KQL text passes through, aggregate names are lowercased |
| `.label()` + `group_by` | `summarize alias = agg() by alias = expr` |
| `having` | `where` after `summarize`; a repeated aggregate (`count(*) > 5`) is replaced by its alias |
| `distinct()` | `distinct` |
| `order_by`, `limit`, `offset` | `order by ... asc/desc`, `take`, `serialize \| where row_number() > n` |
| `join` on a grouped subquery (Superset series limit) | `join kind=inner (<subquery>) on $left.["k"] == $right.["k__"]` |

Bind parameters are always rendered inline (Kusto has none), so statement caching is
off for this dialect. String literals are double-quoted with backslash escaping.

Not supported: DDL/DML, several sources in `FROM`, joins on anything but column
equality, `LIKE` patterns with a bound (non-literal) right-hand side.

For Superset there are two known limitations on the Superset side: a virtual dataset
whose KQL contains `let` statements is rejected as "multiple statements", and custom
SQL expressions go through sqlglot which uppercases function names (the dialect
lowercases them back).

> Notice that implemented Kusto dialects don't support DDL statements and inserts, deletes, updates.

Expand Down
6 changes: 6 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ build-backend = "setuptools.build_meta"

[tool.ruff]
target-version = "py310"
# Hand-off probes for the Superset fork, not part of the package.
extend-exclude = ["docs/handoff"]

# https://beta.ruff.rs/docs/settings/#line-length
line-length = 120
Expand Down Expand Up @@ -70,6 +72,10 @@ lint.ignore = [
"PLR0917", # Too many positional arguments
]

[tool.ruff.lint.per-file-ignores]
# Tests compare against literal KQL text and row counts; naming every number is noise.
"tests/**" = ["PLR2004", "E501", "PLC0415"]

[tool.ruff.lint.pycodestyle]
max-doc-length = 120

Expand Down
3 changes: 2 additions & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

NAME = "sqlalchemy-kusto"
DESCRIPTION = "Azure Data Explorer (Kusto) dialect for SQLAlchemy"
VERSION = "3.2.0"
VERSION = "4.0.0"


REQUIREMENTS = [
Expand Down Expand Up @@ -39,6 +39,7 @@
"kustosql.https = sqlalchemy_kusto.dialect_sql:KustoSqlHttpsDialect",
"kustosql.http = sqlalchemy_kusto.dialect_sql:KustoSqlHttpDialect",
"kustokql.https = sqlalchemy_kusto.dialect_kql:KustoKqlHttpsDialect",
"kustokql.http = sqlalchemy_kusto.dialect_kql:KustoKqlHttpDialect",
]
},
extras_require=EXTRAS,
Expand Down
Loading
Loading