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
19 changes: 12 additions & 7 deletions sqlalchemy_kusto/dialect_kql.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@

from sqlalchemy import Column, exc, sql
from sqlalchemy.sql import compiler, operators, selectable
from sqlalchemy.sql.compiler import OPERATORS

from sqlalchemy_kusto.dialect_base import KustoBaseDialect

Expand Down Expand Up @@ -82,7 +81,6 @@ def __init__(self, dialect, **kw):


class KustoKqlCompiler(compiler.SQLCompiler):
OPERATORS[operators.and_] = " and "
delete_extra_from_clause = None
update_from_clause = None
visit_empty_set_expr = None
Expand Down Expand Up @@ -162,6 +160,18 @@ def visit_select(
def limit_clause(self, select, **kw):
return ""

def visit_clauselist(self, clauselist, **kw):
kql_operators = {
operators.and_: " and ",
operators.or_: " or ",
}

if clauselist.operator in kql_operators:
sep = kql_operators[clauselist.operator]
return self._generate_delimited_list(clauselist.clauses, sep, **kw)

Comment on lines +168 to +172

Copilot AI Feb 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

visit_clauselist() currently ignores clauselist.group / grouping semantics and always returns the raw _generate_delimited_list(...) output for AND/OR. SQLAlchemy uses grouping to emit parentheses for nested boolean expressions (e.g. and_(or_(...), ...)), so this override should preserve that behavior (e.g. wrap the rendered list when grouping is requested) to avoid changing operator precedence in the emitted KQL.

Suggested change
if clauselist.operator in kql_operators:
sep = kql_operators[clauselist.operator]
return self._generate_delimited_list(clauselist.clauses, sep, **kw)
if clauselist.operator in kql_operators:
sep = kql_operators[clauselist.operator]
text = self._generate_delimited_list(clauselist.clauses, sep, **kw)
# Preserve SQLAlchemy's grouping semantics so nested boolean expressions
# get parentheses when required (e.g. and_(or_(...), ...)).
if getattr(clauselist, "group", False):
return f"({text})"
return text

Copilot uses AI. Check for mistakes.
Comment on lines +168 to +172

Copilot AI Feb 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There’s trailing whitespace on the blank lines inside visit_clauselist (will be flagged by linters like Ruff/pycodestyle). Please remove the extra spaces.

Suggested change
if clauselist.operator in kql_operators:
sep = kql_operators[clauselist.operator]
return self._generate_delimited_list(clauselist.clauses, sep, **kw)
if clauselist.operator in kql_operators:
sep = kql_operators[clauselist.operator]
return self._generate_delimited_list(clauselist.clauses, sep, **kw)

Copilot uses AI. Check for mistakes.
return super().visit_clauselist(clauselist, **kw)

def _legacy_join(self, select_stmt: selectable.Select, **kwargs):
"""Consumes arguments from join() or outerjoin(), places them into a
consistent format with which to form the actual JOIN constructs.
Expand Down Expand Up @@ -528,11 +538,6 @@ def _sql_to_kql_where(where_clause: str) -> str:
where_clause,
flags=re.IGNORECASE,
)
# Handle logical operators 'AND' and 'OR' to ensure the conditions are preserved
# Replace AND with 'and' in KQL
where_clause = re.sub(r"\s+AND\s+", r" and ", where_clause, flags=re.IGNORECASE)
# Replace OR with 'or' in KQL
where_clause = re.sub(r"\s+OR\s+", r" or ", where_clause, flags=re.IGNORECASE)
return where_clause

@staticmethod
Expand Down
48 changes: 48 additions & 0 deletions tests/unit/test_dialect_kql.py
Original file line number Diff line number Diff line change
Expand Up @@ -576,3 +576,51 @@ def test_schema_from_query(query_table_name: str, expected_table_name: str):

query_expected = f"let inner_qry = ({expected_table_name});inner_qry| take 5"
assert query_compiled == query_expected


def test_logical_operators_and_literals_precedence():
val_and = "DATA AND ANALYTICS"
val_or = "OPEN OR CLOSED"

condition = sa.and_(
sa.or_(column("Field1") == val_and, column("Field2") == val_or),
column("Status") == "ACTIVE",
)

query = select([column("Field1")]).select_from(text("logs")).where(condition)

query_compiled = str(
query.compile(engine, compile_kwargs={"literal_binds": True})
).replace("\n", " ")

expected_full = (
'["logs"] '
f"| where ([\"Field1\"] == '{val_and}' or [\"Field2\"] == '{val_or}') and [\"Status\"] == 'ACTIVE' "
'| project ["Field1"]'
)
assert query_compiled == expected_full


def test_logical_operators_precedence_and_casing():
"""
Test that 'or' inside 'and' gets parentheses (required),
but 'and' inside 'or' does not (not required by precedence).
"""

cond_nested_or = sa.and_(
sa.or_(column("A") == 1, column("B") == 2),
column("C") == 3
)
query1 = select([column("Field")]).select_from(text("logs")).where(cond_nested_or)
compiled1 = str(query1.compile(engine, compile_kwargs={"literal_binds": True})).replace("\n", " ")

assert '(["A"] == 1 or ["B"] == 2) and ["C"] == 3' in compiled1

cond_nested_and = sa.or_(
column("A") == 1,
sa.and_(column("B") == 2, column("C") == 3)
)
query2 = select([column("Field")]).select_from(text("logs")).where(cond_nested_and)
compiled2 = str(query2.compile(engine, compile_kwargs={"literal_binds": True})).replace("\n", " ")
Comment on lines +612 to +624

Copilot AI Feb 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These new test lines are very long and aren’t formatted consistently with the rest of the file (Black formatting / line-length). Consider wrapping the select_from(...).where(...) and compiledX = ...compile(...).replace(...) statements across multiple lines and adding trailing commas in multi-line calls so formatters don’t churn the diff.

Suggested change
column("C") == 3
)
query1 = select([column("Field")]).select_from(text("logs")).where(cond_nested_or)
compiled1 = str(query1.compile(engine, compile_kwargs={"literal_binds": True})).replace("\n", " ")
assert '(["A"] == 1 or ["B"] == 2) and ["C"] == 3' in compiled1
cond_nested_and = sa.or_(
column("A") == 1,
sa.and_(column("B") == 2, column("C") == 3)
)
query2 = select([column("Field")]).select_from(text("logs")).where(cond_nested_and)
compiled2 = str(query2.compile(engine, compile_kwargs={"literal_binds": True})).replace("\n", " ")
column("C") == 3,
)
query1 = (
select([column("Field")])
.select_from(text("logs"))
.where(cond_nested_or)
)
compiled1 = (
str(query1.compile(engine, compile_kwargs={"literal_binds": True}))
.replace("\n", " ")
)
assert '(["A"] == 1 or ["B"] == 2) and ["C"] == 3' in compiled1
cond_nested_and = sa.or_(
column("A") == 1,
sa.and_(column("B") == 2, column("C") == 3),
)
query2 = (
select([column("Field")])
.select_from(text("logs"))
.where(cond_nested_and)
)
compiled2 = (
str(query2.compile(engine, compile_kwargs={"literal_binds": True}))
.replace("\n", " ")
)

Copilot uses AI. Check for mistakes.

assert '["A"] == 1 or ["B"] == 2 and ["C"] == 3' in compiled2
Loading