This is an automated email from the ASF dual-hosted git repository.

rusackas pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git


The following commit(s) were added to refs/heads/master by this push:
     new bcf0361a919 feat(KustoKQL): Add support for NULL / IS NOT NULL 
operator (#37890)
bcf0361a919 is described below

commit bcf0361a919312331da8e2b4aca40902091d914c
Author: Ramachandran A G <[email protected]>
AuthorDate: Fri Jul 24 06:58:37 2026 +0530

    feat(KustoKQL): Add support for NULL / IS NOT NULL operator (#37890)
    
    Co-authored-by: ag-ramachandran <[email protected]>
    Co-authored-by: Joe Li <[email protected]>
---
 superset/db_engine_specs/kusto.py              | 111 +++++++++-
 tests/unit_tests/db_engine_specs/test_kusto.py |  85 ++++++++
 tests/unit_tests/sql/parse_tests.py            | 268 +++++++++++++++++++++++++
 3 files changed, 460 insertions(+), 4 deletions(-)

diff --git a/superset/db_engine_specs/kusto.py 
b/superset/db_engine_specs/kusto.py
index 7177cd13fb2..85b563262bd 100644
--- a/superset/db_engine_specs/kusto.py
+++ b/superset/db_engine_specs/kusto.py
@@ -14,11 +14,12 @@
 # KIND, either express or implied.  See the License for the
 # specific language governing permissions and limitations
 # under the License.
+import logging
 import re
 from datetime import datetime
-from typing import Any, Optional
+from typing import Any, Optional, TYPE_CHECKING
 
-from sqlalchemy import types
+from sqlalchemy import func, types
 from sqlalchemy.dialects.mssql.base import SMALLDATETIME
 
 from superset.constants import TimeGrain
@@ -28,8 +29,45 @@ from superset.db_engine_specs.exceptions import (
     SupersetDBAPIOperationalError,
     SupersetDBAPIProgrammingError,
 )
-from superset.sql.parse import LimitMethod
-from superset.utils.core import GenericDataType
+
+if TYPE_CHECKING:
+    from superset.models.core import Database
+
+from superset.sql.parse import KQLTokenType, LimitMethod, tokenize_kql
+from superset.utils.core import FilterOperator, GenericDataType
+
+logger = logging.getLogger(__name__)
+
+_OPENING_BRACKET = [
+    (KQLTokenType.WORD, "ARRAY"),
+    (KQLTokenType.OTHER, "("),
+    (KQLTokenType.OTHER, "["),
+]
+_CLOSING_BRACKET = [(KQLTokenType.OTHER, "]"), (KQLTokenType.OTHER, ")")]
+
+
+def strip_array_brackets(kql: str) -> str:
+    """
+    Replace ``ARRAY([...])`` wrappers with ``[...]`` using the KQL tokenizer.
+
+    SQLAlchemy sometimes wraps bracket-quoted KQL identifiers in ARRAY(),
+    which is invalid KQL. This strips the wrapper while preserving the 
contents.
+    """
+    tokens = tokenize_kql(kql)
+
+    to_remove: set[int] = set()
+    depth = 0
+    for i in range(len(tokens)):
+        if tokens[i : i + 3] == _OPENING_BRACKET:
+            to_remove.add(i)
+            to_remove.add(i + 1)
+            depth += 1
+        elif depth > 0 and tokens[i : i + 2] == _CLOSING_BRACKET:
+            to_remove.add(i + 1)
+            depth -= 1
+
+    tokens = [token for i, token in enumerate(tokens) if i not in to_remove]
+    return "".join(val for _, val in tokens)
 
 
 class KustoSqlEngineSpec(BaseEngineSpec):  # pylint: disable=abstract-method
@@ -190,6 +228,14 @@ class KustoKqlEngineSpec(BaseEngineSpec):  # pylint: 
disable=abstract-method
 
     type_code_map: dict[int, str] = {}  # loaded from get_datatype only if 
needed
 
+    column_type_mappings = (
+        (
+            re.compile(r"^array.*", re.IGNORECASE),
+            types.String(),
+            GenericDataType.STRING,
+        ),
+    )
+
     @classmethod
     def get_dbapi_exception_mapping(cls) -> dict[type[Exception], 
type[Exception]]:
         # pylint: disable=import-outside-toplevel,import-error
@@ -201,6 +247,44 @@ class KustoKqlEngineSpec(BaseEngineSpec):  # pylint: 
disable=abstract-method
             kusto_exceptions.ProgrammingError: SupersetDBAPIProgrammingError,
         }
 
+    @classmethod
+    def handle_null_filter(
+        cls,
+        sqla_col: Any,
+        op: FilterOperator,
+    ) -> Any:
+        """
+        Handle null/not null filter operations for KQL.
+
+        In KQL, null checks use functions:
+        - isnull(col) for IS NULL
+        - isnotnull(col) for IS NOT NULL
+
+        :param sqla_col: SQLAlchemy column element
+        :param op: Filter operator (IS_NULL or IS_NOT_NULL)
+        :return: SQLAlchemy expression for the null filter
+        """
+        if op == FilterOperator.IS_NULL:
+            return func.isnull(sqla_col)
+        if op == FilterOperator.IS_NOT_NULL:
+            return func.isnotnull(sqla_col)
+
+        raise ValueError(f"Invalid null filter operator: {op}")
+
+    @classmethod
+    def epoch_to_dttm(cls) -> str:
+        """
+        Convert from number of seconds since the epoch to a timestamp.
+        """
+        return "unixtime_seconds_todatetime({col})"
+
+    @classmethod
+    def epoch_ms_to_dttm(cls) -> str:
+        """
+        Convert from number of milliseconds since the epoch to a timestamp.
+        """
+        return "unixtime_milliseconds_todatetime({col})"
+
     @classmethod
     def convert_dttm(
         cls, target_type: str, dttm: datetime, db_extra: Optional[dict[str, 
Any]] = None
@@ -213,3 +297,22 @@ class KustoKqlEngineSpec(BaseEngineSpec):  # pylint: 
disable=abstract-method
             return f"""datetime({dttm.isoformat(timespec="microseconds")})"""
 
         return None
+
+    @classmethod
+    def execute(
+        cls,
+        cursor: Any,
+        query: str,
+        database: "Database",
+        **kwargs: Any,
+    ) -> None:
+        """
+        Execute a KQL query, fixing ARRAY() wrappers around
+        bracket-quoted identifiers.
+
+        Example:
+            ARRAY(["age"]) -> ["age"]
+            ARRAY(["user_name"]) -> ["user_name"]
+        """
+        processed_query = strip_array_brackets(query)
+        super().execute(cursor, processed_query, database, **kwargs)
diff --git a/tests/unit_tests/db_engine_specs/test_kusto.py 
b/tests/unit_tests/db_engine_specs/test_kusto.py
index a21e82a5676..568e90153a8 100644
--- a/tests/unit_tests/db_engine_specs/test_kusto.py
+++ b/tests/unit_tests/db_engine_specs/test_kusto.py
@@ -139,3 +139,88 @@ def test_timegrain_expressions(in_duration: str, 
expected_result: str) -> None:
         col=col, pdf=None, time_grain=in_duration
     )
     assert str(actual_result) == expected_result
+
+
+def test_epoch_to_dttm() -> None:
+    """
+    Test that KQL engine spec returns correct epoch to datetime conversion 
template.
+    """
+    result = KustoKqlEngineSpec.epoch_to_dttm()
+    assert result == "unixtime_seconds_todatetime({col})"
+
+
+def test_epoch_ms_to_dttm() -> None:
+    """
+    Test that KQL engine spec returns correct epoch milliseconds to
+    datetime conversion template.
+    """
+    result = KustoKqlEngineSpec.epoch_ms_to_dttm()
+    assert result == "unixtime_milliseconds_todatetime({col})"
+
+
+def test_handle_null_filter() -> None:
+    """
+    Test that KQL engine spec uses isnull/isnotnull functions for null filters.
+    """
+    from superset.utils.core import FilterOperator
+
+    test_col = column("test_column")
+
+    # Test IS_NULL - should return isnull(col)
+    result_null = KustoKqlEngineSpec.handle_null_filter(
+        test_col, FilterOperator.IS_NULL
+    )
+    assert str(result_null) == "isnull(test_column)"
+
+    # Test IS_NOT_NULL - should return isnotnull(col)
+    result_not_null = KustoKqlEngineSpec.handle_null_filter(
+        test_col, FilterOperator.IS_NOT_NULL
+    )
+    assert str(result_not_null) == "isnotnull(test_column)"
+
+    # Test invalid operator - should raise ValueError
+    with pytest.raises(ValueError, match="Invalid null filter operator"):
+        KustoKqlEngineSpec.handle_null_filter(test_col, "INVALID_OPERATOR")  # 
type: ignore[arg-type]
+
+
[email protected](
+    ("raw_query", "expected_query"),
+    [
+        (
+            'database("superset").["FreeCodeCamp"] | extend ["age"] = 
ARRAY(["age"]) '
+            '| project ["age"] | take 100',
+            'database("superset").["FreeCodeCamp"] | extend ["age"] = ["age"] '
+            '| project ["age"] | take 100',
+        ),
+        (
+            'database("superset").["FreeCodeCamp"] | project ["age"] | take 
100',
+            'database("superset").["FreeCodeCamp"] | project ["age"] | take 
100',
+        ),
+        (
+            'database("superset").["VideoGameSales"]'
+            ' | where ["rank"]<= 25'
+            ' | summarize ["SUM(Global_Sales)"] = sum(["global_sales"])'
+            '  by ["publisher"]'
+            ' | project ["publisher"], ["SUM(Global_Sales)"]'
+            ' | order by ["SUM(Global_Sales)"] desc'
+            " | take 50000",
+            'database("superset").["VideoGameSales"]'
+            ' | where ["rank"]<= 25'
+            ' | summarize ["SUM(Global_Sales)"] = sum(["global_sales"])'
+            '  by ["publisher"]'
+            ' | project ["publisher"], ["SUM(Global_Sales)"]'
+            ' | order by ["SUM(Global_Sales)"] desc'
+            " | take 50000",
+        ),
+    ],
+)
+def test_kql_execute_array_processing(raw_query: str, expected_query: str) -> 
None:
+    """Ensure `execute` replaces ARRAY wrappers and leaves other queries 
unchanged."""
+    from unittest.mock import Mock
+
+    mock_cursor = Mock()
+    mock_db = Mock()
+
+    KustoKqlEngineSpec.execute(mock_cursor, raw_query, mock_db)
+
+    mock_cursor.execute.assert_called_once_with(expected_query)
diff --git a/tests/unit_tests/sql/parse_tests.py 
b/tests/unit_tests/sql/parse_tests.py
index 206ebc6ea72..bec1849a196 100644
--- a/tests/unit_tests/sql/parse_tests.py
+++ b/tests/unit_tests/sql/parse_tests.py
@@ -4121,6 +4121,274 @@ def test_kustokql_statement_check_tables_present() -> 
None:
         ),
         ("'test'", [(KQLTokenType.STRING, "'test'")]),
         ("```test```", [(KQLTokenType.STRING, "```test```")]),
+        # Double-quoted strings
+        ('"hello"', [(KQLTokenType.STRING, '"hello"')]),
+        # Single-quoted string with escaped quote
+        (
+            "'it\\'s a test'",
+            [(KQLTokenType.STRING, "'it\\'s a test'")],
+        ),
+        # Double-quoted string with escaped quote
+        (
+            '"say \\"hi\\""',
+            [(KQLTokenType.STRING, '"say \\"hi\\""')],
+        ),
+        # Semicolon token
+        (
+            "a; b",
+            [
+                (KQLTokenType.WORD, "a"),
+                (KQLTokenType.SEMICOLON, ";"),
+                (KQLTokenType.WHITESPACE, " "),
+                (KQLTokenType.WORD, "b"),
+            ],
+        ),
+        # Semicolon inside string is not a SEMICOLON token
+        (
+            "'a;b'",
+            [(KQLTokenType.STRING, "'a;b'")],
+        ),
+        # Numbers
+        (
+            "42",
+            [(KQLTokenType.NUMBER, "42")],
+        ),
+        # Other/punctuation tokens
+        (
+            "()",
+            [
+                (KQLTokenType.OTHER, "("),
+                (KQLTokenType.OTHER, ")"),
+            ],
+        ),
+        # Empty input
+        ("", []),
+        # ARRAY bracket pattern used in Kusto engine spec
+        (
+            'ARRAY(["age"])',
+            [
+                (KQLTokenType.WORD, "ARRAY"),
+                (KQLTokenType.OTHER, "("),
+                (KQLTokenType.OTHER, "["),
+                (KQLTokenType.STRING, '"age"'),
+                (KQLTokenType.OTHER, "]"),
+                (KQLTokenType.OTHER, ")"),
+            ],
+        ),
+        # Mixed identifiers, operators, and strings
+        (
+            "tbl | where name == 'Alice' | take 5",
+            [
+                (KQLTokenType.WORD, "tbl"),
+                (KQLTokenType.WHITESPACE, " "),
+                (KQLTokenType.OTHER, "|"),
+                (KQLTokenType.WHITESPACE, " "),
+                (KQLTokenType.WORD, "where"),
+                (KQLTokenType.WHITESPACE, " "),
+                (KQLTokenType.WORD, "name"),
+                (KQLTokenType.WHITESPACE, " "),
+                (KQLTokenType.OTHER, "="),
+                (KQLTokenType.OTHER, "="),
+                (KQLTokenType.WHITESPACE, " "),
+                (KQLTokenType.STRING, "'Alice'"),
+                (KQLTokenType.WHITESPACE, " "),
+                (KQLTokenType.OTHER, "|"),
+                (KQLTokenType.WHITESPACE, " "),
+                (KQLTokenType.WORD, "take"),
+                (KQLTokenType.WHITESPACE, " "),
+                (KQLTokenType.NUMBER, "5"),
+            ],
+        ),
+        # Underscore in identifiers
+        (
+            "my_table",
+            [(KQLTokenType.WORD, "my_table")],
+        ),
+        # Identifiers starting with underscore
+        (
+            "_col1",
+            [(KQLTokenType.WORD, "_col1")],
+        ),
+        # Multiline string with semicolons and quotes
+        (
+            "```select 'x'; drop```",
+            [(KQLTokenType.STRING, "```select 'x'; drop```")],
+        ),
+        # Adjacent strings without whitespace
+        (
+            "'a''b'",
+            [
+                (KQLTokenType.STRING, "'a'"),
+                (KQLTokenType.STRING, "'b'"),
+            ],
+        ),
+        # Dot operator
+        (
+            "db.table",
+            [
+                (KQLTokenType.WORD, "db"),
+                (KQLTokenType.OTHER, "."),
+                (KQLTokenType.WORD, "table"),
+            ],
+        ),
+        # Bracket-quoted identifier (KQL style)
+        (
+            '["column name"]',
+            [
+                (KQLTokenType.OTHER, "["),
+                (KQLTokenType.STRING, '"column name"'),
+                (KQLTokenType.OTHER, "]"),
+            ],
+        ),
+        # Whitespace variants (tab, newline)
+        (
+            "a\t\nb",
+            [
+                (KQLTokenType.WORD, "a"),
+                (KQLTokenType.WHITESPACE, "\t\n"),
+                (KQLTokenType.WORD, "b"),
+            ],
+        ),
+        # Summarize with count aggregation
+        (
+            "T | summarize count() by State",
+            [
+                (KQLTokenType.WORD, "T"),
+                (KQLTokenType.WHITESPACE, " "),
+                (KQLTokenType.OTHER, "|"),
+                (KQLTokenType.WHITESPACE, " "),
+                (KQLTokenType.WORD, "summarize"),
+                (KQLTokenType.WHITESPACE, " "),
+                (KQLTokenType.WORD, "count"),
+                (KQLTokenType.OTHER, "("),
+                (KQLTokenType.OTHER, ")"),
+                (KQLTokenType.WHITESPACE, " "),
+                (KQLTokenType.WORD, "by"),
+                (KQLTokenType.WHITESPACE, " "),
+                (KQLTokenType.WORD, "State"),
+            ],
+        ),
+        # Aliased aggregation with avg
+        (
+            "T | summarize avg_val = avg(price) by category",
+            [
+                (KQLTokenType.WORD, "T"),
+                (KQLTokenType.WHITESPACE, " "),
+                (KQLTokenType.OTHER, "|"),
+                (KQLTokenType.WHITESPACE, " "),
+                (KQLTokenType.WORD, "summarize"),
+                (KQLTokenType.WHITESPACE, " "),
+                (KQLTokenType.WORD, "avg_val"),
+                (KQLTokenType.WHITESPACE, " "),
+                (KQLTokenType.OTHER, "="),
+                (KQLTokenType.WHITESPACE, " "),
+                (KQLTokenType.WORD, "avg"),
+                (KQLTokenType.OTHER, "("),
+                (KQLTokenType.WORD, "price"),
+                (KQLTokenType.OTHER, ")"),
+                (KQLTokenType.WHITESPACE, " "),
+                (KQLTokenType.WORD, "by"),
+                (KQLTokenType.WHITESPACE, " "),
+                (KQLTokenType.WORD, "category"),
+            ],
+        ),
+        # Multiple aggregations with dcount
+        (
+            "T | summarize cnt = count(), uniq = dcount(user_id)",
+            [
+                (KQLTokenType.WORD, "T"),
+                (KQLTokenType.WHITESPACE, " "),
+                (KQLTokenType.OTHER, "|"),
+                (KQLTokenType.WHITESPACE, " "),
+                (KQLTokenType.WORD, "summarize"),
+                (KQLTokenType.WHITESPACE, " "),
+                (KQLTokenType.WORD, "cnt"),
+                (KQLTokenType.WHITESPACE, " "),
+                (KQLTokenType.OTHER, "="),
+                (KQLTokenType.WHITESPACE, " "),
+                (KQLTokenType.WORD, "count"),
+                (KQLTokenType.OTHER, "("),
+                (KQLTokenType.OTHER, ")"),
+                (KQLTokenType.OTHER, ","),
+                (KQLTokenType.WHITESPACE, " "),
+                (KQLTokenType.WORD, "uniq"),
+                (KQLTokenType.WHITESPACE, " "),
+                (KQLTokenType.OTHER, "="),
+                (KQLTokenType.WHITESPACE, " "),
+                (KQLTokenType.WORD, "dcount"),
+                (KQLTokenType.OTHER, "("),
+                (KQLTokenType.WORD, "user_id"),
+                (KQLTokenType.OTHER, ")"),
+            ],
+        ),
+        # Summarize with bin time bucketing
+        (
+            "T | summarize count() by bin(ts, 1h)",
+            [
+                (KQLTokenType.WORD, "T"),
+                (KQLTokenType.WHITESPACE, " "),
+                (KQLTokenType.OTHER, "|"),
+                (KQLTokenType.WHITESPACE, " "),
+                (KQLTokenType.WORD, "summarize"),
+                (KQLTokenType.WHITESPACE, " "),
+                (KQLTokenType.WORD, "count"),
+                (KQLTokenType.OTHER, "("),
+                (KQLTokenType.OTHER, ")"),
+                (KQLTokenType.WHITESPACE, " "),
+                (KQLTokenType.WORD, "by"),
+                (KQLTokenType.WHITESPACE, " "),
+                (KQLTokenType.WORD, "bin"),
+                (KQLTokenType.OTHER, "("),
+                (KQLTokenType.WORD, "ts"),
+                (KQLTokenType.OTHER, ","),
+                (KQLTokenType.WHITESPACE, " "),
+                (KQLTokenType.NUMBER, "1"),
+                (KQLTokenType.WORD, "h"),
+                (KQLTokenType.OTHER, ")"),
+            ],
+        ),
+        (
+            "T | summarize dcountif(user_id, status == 'active') by region",
+            [
+                (KQLTokenType.WORD, "T"),
+                (KQLTokenType.WHITESPACE, " "),
+                (KQLTokenType.OTHER, "|"),
+                (KQLTokenType.WHITESPACE, " "),
+                (KQLTokenType.WORD, "summarize"),
+                (KQLTokenType.WHITESPACE, " "),
+                (KQLTokenType.WORD, "dcountif"),
+                (KQLTokenType.OTHER, "("),
+                (KQLTokenType.WORD, "user_id"),
+                (KQLTokenType.OTHER, ","),
+                (KQLTokenType.WHITESPACE, " "),
+                (KQLTokenType.WORD, "status"),
+                (KQLTokenType.WHITESPACE, " "),
+                (KQLTokenType.OTHER, "="),
+                (KQLTokenType.OTHER, "="),
+                (KQLTokenType.WHITESPACE, " "),
+                (KQLTokenType.STRING, "'active'"),
+                (KQLTokenType.OTHER, ")"),
+                (KQLTokenType.WHITESPACE, " "),
+                (KQLTokenType.WORD, "by"),
+                (KQLTokenType.WHITESPACE, " "),
+                (KQLTokenType.WORD, "region"),
+            ],
+        ),
+        (
+            "T | project tostring(value)",
+            [
+                (KQLTokenType.WORD, "T"),
+                (KQLTokenType.WHITESPACE, " "),
+                (KQLTokenType.OTHER, "|"),
+                (KQLTokenType.WHITESPACE, " "),
+                (KQLTokenType.WORD, "project"),
+                (KQLTokenType.WHITESPACE, " "),
+                (KQLTokenType.WORD, "tostring"),
+                (KQLTokenType.OTHER, "("),
+                (KQLTokenType.WORD, "value"),
+                (KQLTokenType.OTHER, ")"),
+            ],
+        ),
     ],
 )
 def test_tokenize_kql(kql: str, expected: list[tuple[KQLTokenType, str]]) -> 
None:

Reply via email to