From 5548c47e798d687ea0332e50dbded1cd6186e646 Mon Sep 17 00:00:00 2001 From: Alison Gim Date: Thu, 5 Feb 2026 20:49:20 +0000 Subject: [PATCH 1/8] Move extend clause after summarize for calculated measures This ensures that extend operations (which may reference summarized columns) are executed after the summarize clause in the generated KQL query. --- sqlalchemy_kusto/dialect_kql.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/sqlalchemy_kusto/dialect_kql.py b/sqlalchemy_kusto/dialect_kql.py index 5be6be3..0cc0bd0 100644 --- a/sqlalchemy_kusto/dialect_kql.py +++ b/sqlalchemy_kusto/dialect_kql.py @@ -142,9 +142,15 @@ def visit_select( ) compiled_query_lines.append(f"| where {converted_where_clause}") + # Add summarize first if it exists + if "summarize" in projections_parts_dict: + compiled_query_lines.append(projections_parts_dict.pop("summarize")) + + # Then add extend after summarize if "extend" in projections_parts_dict: compiled_query_lines.append(projections_parts_dict.pop("extend")) + # Add remaining parts (project, sort) for statement_part in projections_parts_dict.values(): if statement_part: compiled_query_lines.append(statement_part) From 062cf9023c41238bddd1e5e8563d85de46e56865 Mon Sep 17 00:00:00 2001 From: Alison Gim Date: Fri, 6 Feb 2026 00:50:38 +0000 Subject: [PATCH 2/8] Add quote-to-bracket escaping for calculated measures with multiple ad hoc measures - Add _find_operator_outside_quotes() helper to find operators not inside quoted strings - Update _escape_and_quote_columns() to recursively escape both sides of operators - Update _is_number_literal() to match integers and decimals with digits on both sides - Add tests for: - Two quoted measures: "Measure 1" + "Measure 2" -> ["Measure 1"] + ["Measure 2"] - Measure with constant: "Measure 1" * 2 -> ["Measure 1"] * 2 - Measure with operator in name: "Measure 1-2" -> ["Measure 1-2"] - _is_number_literal function validation --- sqlalchemy_kusto/dialect_kql.py | 48 ++++++---- tests/unit/test_dialect_kql.py | 149 +++++++++++++++++++++++++++++--- 2 files changed, 167 insertions(+), 30 deletions(-) diff --git a/sqlalchemy_kusto/dialect_kql.py b/sqlalchemy_kusto/dialect_kql.py index 0cc0bd0..f5b1922 100644 --- a/sqlalchemy_kusto/dialect_kql.py +++ b/sqlalchemy_kusto/dialect_kql.py @@ -107,7 +107,7 @@ def visit_select( from_object = select_stmt.get_final_froms()[0] if hasattr(from_object, "element"): query = self._get_most_inner_element(from_object.element) - (main, lets) = self._extract_let_statements(query.text) + main, lets = self._extract_let_statements(query.text) compiled_query_lines.extend(lets) compiled_query_lines.append( f"let {from_object.name} = ({self._convert_schema_in_statement(main)});" @@ -362,29 +362,41 @@ def _escape_and_quote_columns(name: str | None, is_alias=False) -> str: or KustoKqlCompiler._is_number_literal(name) ) and not is_alias: return name - if name.startswith('"') and name.endswith('"'): - name = name[1:-1] - # First, check if the name is already wrapped in ["ColumnName"] (escaped format) + # Check if already wrapped in ["ColumnName"] (escaped format) if name.startswith('["') and name.endswith('"]'): return name # Return as is if already properly escaped - # Remove surrounding spaces - # Handle mathematical operations (wrap only the column part before operators) - # Find the position of the first operator or space that separates the column name + # Handle arithmetic expressions by recursively escaping both sides + # Check for operators BEFORE stripping quotes to handle "Measure 1" + "Measure 2" if not is_alias: for operator in ["/", "+", "-", "*"]: - if operator in name: - # Split the name at the first operator and wrap the left part - parts = name.split(operator, 1) - # Remove quotes if they exist at the edges - col_part = parts[0].strip() - if col_part.startswith('"') and col_part.endswith('"'): - col_part = col_part[1:-1].strip() - col_part = col_part.replace('"', '\\"') - return f'["{col_part}"] {operator} {parts[1].strip()}' # Wrap the column part - # No operators found, just wrap the entire name + # Find operator that's not inside quotes + pos = KustoKqlCompiler._find_operator_outside_quotes(name, operator) + if pos != -1: + left_part = name[:pos].strip() + right_part = name[pos + 1 :].strip() + # Recursively escape both sides + left_escaped = KustoKqlCompiler._escape_and_quote_columns(left_part) + right_escaped = KustoKqlCompiler._escape_and_quote_columns( + right_part + ) + return f"{left_escaped} {operator} {right_escaped}" + # No operators found - strip surrounding quotes if present, then wrap + if name.startswith('"') and name.endswith('"'): + name = name[1:-1] name = name.replace('"', '\\"') return f'["{name}"]' + @staticmethod + def _find_operator_outside_quotes(text: str, operator: str) -> int: + """Find position of operator that's not inside quoted strings. Returns -1 if not found.""" + in_quotes = False + for i, ch in enumerate(text): + if ch == '"' and (i == 0 or text[i - 1] != "\\"): + in_quotes = not in_quotes + elif ch == operator and not in_quotes: + return i + return -1 + @staticmethod def _sql_to_kql_where(where_clause: str) -> str: where_clause = where_clause.strip().replace("\n", "") @@ -553,7 +565,7 @@ def _is_kql_function(name: str) -> bool: @staticmethod def _is_number_literal(s: str) -> bool: - pattern = r"^[0-9]+$" + pattern = r"^\d+(\.\d+)?$" return bool(re.match(pattern, s)) def _get_most_inner_element(self, clause): diff --git a/tests/unit/test_dialect_kql.py b/tests/unit/test_dialect_kql.py index 04b8fc0..d3e4917 100644 --- a/tests/unit/test_dialect_kql.py +++ b/tests/unit/test_dialect_kql.py @@ -175,9 +175,10 @@ def test_group_by_text(): ).replace("\n", "") # raw query text from query query_expected = ( - '["ActiveUsersLastMonth"]| extend ["ActiveUserMetric"] = ["ActiveUsers"], ' - '["EventInfo_Time"] = ["EventInfo_Time"] / time(1d)' + '["ActiveUsersLastMonth"]' '| summarize by ["EventInfo_Time"] / time(1d)' + '| extend ["ActiveUserMetric"] = ["ActiveUsers"], ' + '["EventInfo_Time"] = ["EventInfo_Time"] / time(1d)' '| project ["EventInfo_Time"], ["ActiveUserMetric"]' '| order by ["ActiveUserMetric"] desc' ) @@ -224,20 +225,19 @@ def test_group_by_text_vaccine_dataset(): query.compile(engine, compile_kwargs={"literal_binds": True}) ).replace("\n", "") query_expected = ( - 'database("superset").["CovidVaccineData"]| ' - 'extend ["country_name"] = ["country_name"]| ' - 'summarize by ["country_name"]| ' - 'project ["country_name"]| order by ["country_name"] asc' + 'database("superset").["CovidVaccineData"]' + '| summarize by ["country_name"]' + '| extend ["country_name"] = ["country_name"]' + '| project ["country_name"]' + '| order by ["country_name"] asc' ) assert query_compiled == query_expected def test_is_kql_function(): - assert KustoKqlCompiler._is_kql_function( - """case(Size <= 3, "Small", + assert KustoKqlCompiler._is_kql_function("""case(Size <= 3, "Small", Size <= 10, "Medium", - "Large")""" - ) + "Large")""") assert KustoKqlCompiler._is_kql_function("""bin(time(16d), 7d)""") assert KustoKqlCompiler._is_kql_function( """iff((EventType in ("Heavy Rain", "Flash Flood", "Flood")), "Rain event", "Not rain event")""" @@ -328,8 +328,8 @@ def test_distinct_count_by_text(): # raw query text from query query_expected = ( '["ActiveUsersLastMonth"]' - '| extend ["EventInfo_Time"] = ["EventInfo_Time"] / time(1d)' '| summarize ["DistinctUsers"] = dcount(["ActiveUsers"]) by ["EventInfo_Time"] / time(1d)' + '| extend ["EventInfo_Time"] = ["EventInfo_Time"] / time(1d)' '| project ["EventInfo_Time"], ["DistinctUsers"]' '| order by ["ActiveUserMetric"] desc' ) @@ -354,8 +354,8 @@ def test_distinct_count_alt_by_text(): # raw query text from query query_expected = ( '["ActiveUsersLastMonth"]' - '| extend ["EventInfo_Time"] = ["EventInfo_Time"] / time(1d)' '| summarize ["DistinctUsers"] = dcount(["ActiveUsers"]) by ["EventInfo_Time"] / time(1d)' + '| extend ["EventInfo_Time"] = ["EventInfo_Time"] / time(1d)' '| project ["EventInfo_Time"], ["DistinctUsers"]' '| order by ["ActiveUserMetric"] desc' ) @@ -549,6 +549,131 @@ def test_match_aggregates(column_name: str, expected_aggregate: str): assert kql_agg is None +def test_escape_and_quote_columns_with_two_quoted_measures(): + """Test that two quoted measure names with operator are properly escaped. + + e.g. "Measure 1" + "Measure 2" --> ["Measure 1"] + ["Measure 2"] + """ + result = KustoKqlCompiler._escape_and_quote_columns('"Measure 1" + "Measure 2"') + assert result == '["Measure 1"] + ["Measure 2"]' + + +def test_escape_and_quote_columns_preserves_already_bracketed(): + """Test that already-bracketed columns are not double-converted.""" + result = KustoKqlCompiler._escape_and_quote_columns('["Measure 1"]') + assert result == '["Measure 1"]' + + +def test_calculated_measure_with_two_adhoc_measures(): + """Test calculated measure referencing two ad hoc measures. + + Measure 3 = "Measure 1" + "Measure 2" should compile to ["Measure 1"] + ["Measure 2"] + """ + measure_3 = literal_column('"Measure 1" + "Measure 2"').label("Measure 3") + query = select([measure_3]).select_from(text("SalesData")) + query_compiled = str( + query.compile(engine, compile_kwargs={"literal_binds": True}) + ).replace("\n", "") + query_expected = ( + '["SalesData"]' + '| extend ["Measure 3"] = ["Measure 1"] + ["Measure 2"]' + '| project ["Measure 3"]' + ) + assert query_compiled == query_expected + + +def test_escape_and_quote_columns_measure_with_constant(): + """Test that measure with operator and constant is properly escaped. + + e.g. "Measure 1" * 2 --> ["Measure 1"] * 2 + """ + result = KustoKqlCompiler._escape_and_quote_columns('"Measure 1" * 2') + assert result == '["Measure 1"] * 2' + + +def test_escape_and_quote_columns_measure_with_operator_in_name(): + """Test that measure names containing operators are properly escaped. + + e.g. "Measure 1-2" --> ["Measure 1-2"] (not split as ["Measure 1"] - ["2"]) + """ + result = KustoKqlCompiler._escape_and_quote_columns('"Measure 1-2"') + assert result == '["Measure 1-2"]' + + +def test_is_number_literal(): + """Test _is_number_literal correctly identifies numeric literals.""" + # Should match: integers and decimals with digits on both sides of decimal + assert KustoKqlCompiler._is_number_literal("5") is True + assert KustoKqlCompiler._is_number_literal("123") is True + assert KustoKqlCompiler._is_number_literal("0") is True + assert KustoKqlCompiler._is_number_literal("0.5") is True + assert KustoKqlCompiler._is_number_literal("5.0") is True + assert KustoKqlCompiler._is_number_literal("123.456") is True + + # Should NOT match: trailing decimal, leading decimal, scientific notation, negatives + assert KustoKqlCompiler._is_number_literal("5.") is False + assert KustoKqlCompiler._is_number_literal(".5") is False + assert KustoKqlCompiler._is_number_literal("-5") is False + assert KustoKqlCompiler._is_number_literal("-0.5") is False + assert KustoKqlCompiler._is_number_literal("1e10") is False + assert KustoKqlCompiler._is_number_literal("1.5e-3") is False + + # Should NOT match: non-numeric strings + assert KustoKqlCompiler._is_number_literal("abc") is False + assert KustoKqlCompiler._is_number_literal("Measure 1") is False + assert KustoKqlCompiler._is_number_literal("") is False + + +def test_calculated_measure_with_adhoc_measure_and_constant(): + """Test calculated measure with an ad hoc measure and a constant. + + Measure 1 = count(*), Measure 2 = "Measure 1" * 2 + Measure 2 should compile to ["Measure 1"] * 2 + """ + measure_1 = literal_column("count(*)").label("Measure 1") + measure_2 = literal_column('"Measure 1" * 2').label("Measure 2") + query = select([measure_1, measure_2]).select_from(text("SalesData")) + query_compiled = str( + query.compile(engine, compile_kwargs={"literal_binds": True}) + ).replace("\n", "") + query_expected = ( + '["SalesData"]' + '| summarize ["Measure 1"] = count() ' + '| extend ["Measure 2"] = ["Measure 1"] * 2' + '| project ["Measure 1"], ["Measure 2"]' + ) + assert query_compiled == query_expected + + +def test_calculated_measure_with_two_adhoc_measures_and_aggregates(): + """Test calculated measure referencing two ad hoc measures with aggregates. + + Measure 1 = count(*), Measure 2 = count(*) + Measure 3 = "Measure 1" + "Measure 2" should compile to ["Measure 1"] + ["Measure 2"] + """ + measure_1 = literal_column("count(*)").label("Measure 1") + measure_2 = literal_column("count(*)").label("Measure 2") + measure_3 = literal_column('"Measure 1" + "Measure 2"').label("Measure 3") + query = select([measure_1, measure_2, measure_3]).select_from(text("SalesData")) + query_compiled = str( + query.compile(engine, compile_kwargs={"literal_binds": True}) + ).replace("\n", "") + # Summarize columns come from a set so order may vary + query_expected_1 = ( + '["SalesData"]' + '| summarize ["Measure 1"] = count(), ["Measure 2"] = count() ' + '| extend ["Measure 3"] = ["Measure 1"] + ["Measure 2"]' + '| project ["Measure 1"], ["Measure 2"], ["Measure 3"]' + ) + query_expected_2 = ( + '["SalesData"]' + '| summarize ["Measure 2"] = count(), ["Measure 1"] = count() ' + '| extend ["Measure 3"] = ["Measure 1"] + ["Measure 2"]' + '| project ["Measure 1"], ["Measure 2"], ["Measure 3"]' + ) + assert query_compiled in (query_expected_1, query_expected_2) + + @pytest.mark.parametrize( ("query_table_name", "expected_table_name"), [ From fdd385106d1737ec84c2cbd13a05e7ec2411cd27 Mon Sep 17 00:00:00 2001 From: Alison Gim Date: Fri, 6 Feb 2026 00:58:46 +0000 Subject: [PATCH 3/8] Clean up comments in _escape_and_quote_columns --- sqlalchemy_kusto/dialect_kql.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/sqlalchemy_kusto/dialect_kql.py b/sqlalchemy_kusto/dialect_kql.py index f5b1922..c10926a 100644 --- a/sqlalchemy_kusto/dialect_kql.py +++ b/sqlalchemy_kusto/dialect_kql.py @@ -362,11 +362,10 @@ def _escape_and_quote_columns(name: str | None, is_alias=False) -> str: or KustoKqlCompiler._is_number_literal(name) ) and not is_alias: return name - # Check if already wrapped in ["ColumnName"] (escaped format) + # First, check if the name is already wrapped in ["ColumnName"] (escaped format) if name.startswith('["') and name.endswith('"]'): return name # Return as is if already properly escaped # Handle arithmetic expressions by recursively escaping both sides - # Check for operators BEFORE stripping quotes to handle "Measure 1" + "Measure 2" if not is_alias: for operator in ["/", "+", "-", "*"]: # Find operator that's not inside quotes From 1246f27f24552bad0d213983a49c8e8bd3f8aa03 Mon Sep 17 00:00:00 2001 From: Alison Gim Date: Sat, 7 Feb 2026 02:25:34 +0000 Subject: [PATCH 4/8] Add unit tests for helper functions and code cleanup - Add comprehensive unit tests for new helper functions: - TestIsInsideQuotesOrBrackets (10 tests) - TestFindMatchingParen (8 tests) - TestHasOperatorsOutsideQuotes (8 tests) - TestExtractAndReplaceAggregates (10 tests) - TestContainsAggregateFunction (10 tests) - Add comment explaining existing_aggs registration logic - Apply black formatting --- sqlalchemy_kusto/dialect_kql.py | 234 ++++++++++++++++++++-- tests/unit/test_dialect_kql.py | 331 ++++++++++++++++++++++++++++++-- 2 files changed, 529 insertions(+), 36 deletions(-) diff --git a/sqlalchemy_kusto/dialect_kql.py b/sqlalchemy_kusto/dialect_kql.py index c10926a..ad8708b 100644 --- a/sqlalchemy_kusto/dialect_kql.py +++ b/sqlalchemy_kusto/dialect_kql.py @@ -66,6 +66,9 @@ "variancep", } AGGREGATE_PATTERN = r"(\w+)\s*\(\s*(DISTINCT|distinct\s*)?\(?\s*(\*|\[?\"?\'?\w+\"?\]?)\s*(,.+)*\)?\s*\)" +KQL_AGG_PATTERN = re.compile( + r"\b(" + "|".join(kql_aggregates) + r")\s*\(", re.IGNORECASE +) class UniversalSet: @@ -89,6 +92,148 @@ class KustoKqlCompiler(compiler.SQLCompiler): visit_sequence = None sort_with_clause_parts = 2 + @staticmethod + def _is_inside_quotes_or_brackets(text: str, pos: int) -> bool: + """Check if a position in text is inside quotes or brackets.""" + if pos >= len(text): + return False + + in_double_quote = False + in_single_quote = False + in_bracket = False + + for i in range(pos): + ch = text[i] + prev_ch = text[i - 1] if i > 0 else None + if ch == '"' and prev_ch != "\\" and not in_single_quote and not in_bracket: + in_double_quote = not in_double_quote + elif ( + ch == "'" and prev_ch != "\\" and not in_double_quote and not in_bracket + ): + in_single_quote = not in_single_quote + elif not in_double_quote and not in_single_quote: + if ch == "[": + in_bracket = True + elif ch == "]": + in_bracket = False + + return in_double_quote or in_single_quote or in_bracket + + @staticmethod + def _find_matching_paren(text: str, start_pos: int) -> int: + """Find the matching closing parenthesis for an opening paren at start_pos.""" + if start_pos >= len(text) or text[start_pos] != "(": + return -1 + + in_double_quote = False + in_single_quote = False + in_bracket = False + paren_depth = 1 + + for i in range(start_pos + 1, len(text)): + ch = text[i] + prev_ch = text[i - 1] if i > 0 else None + + if ch == '"' and prev_ch != "\\" and not in_single_quote and not in_bracket: + in_double_quote = not in_double_quote + elif ( + ch == "'" and prev_ch != "\\" and not in_double_quote and not in_bracket + ): + in_single_quote = not in_single_quote + elif not in_double_quote and not in_single_quote: + if ch == "[": + in_bracket = True + elif ch == "]": + in_bracket = False + elif not in_bracket: + if ch == "(": + paren_depth += 1 + elif ch == ")": + paren_depth -= 1 + if paren_depth == 0: + return i + return -1 + + @staticmethod + def _has_operators_outside_quotes(expr: str) -> bool: + """Check if expression has arithmetic operators outside of quoted strings.""" + for operator in "+-*/": + pos = KustoKqlCompiler._find_operator_outside_quotes(expr, operator) + if pos != -1: + return True + return False + + @staticmethod + def _extract_and_replace_aggregates( + expr: str, measure_name: str, existing_aggs: dict[str, str] | None = None + ) -> tuple[str, list[tuple[str, str]]]: + """Extract aggregate functions from an expression and replace with references. + + Args: + expr: The expression to process (may contain aggregates, operators) + measure_name: Name of the parent measure (used for generating ref names) + existing_aggs: Dict mapping kql_agg (lowercase) -> ref_name for reuse. + + Returns: + A tuple of: + - modified expression with aggregates replaced by references like ["__measure_1"] + - list of (ref_name, kql_aggregate) tuples to add to summarize (only NEW ones) + """ + if existing_aggs is None: + existing_aggs = {} + + new_aggregates: list[tuple[str, str]] = [] + agg_counter = 0 + + # Collect replacements: (start, end, ref_name) + replacements: list[tuple[int, int, str]] = [] + + for match in KQL_AGG_PATTERN.finditer(expr): + start = match.start() + paren_end = KustoKqlCompiler._find_matching_paren(expr, match.end() - 1) + kql_agg = ( + KustoKqlCompiler._extract_maybe_agg_column_parts( + expr[start : paren_end + 1] + ) + if paren_end != -1 + else None + ) + + # Skip invalid matches + if ( + KustoKqlCompiler._is_inside_quotes_or_brackets(expr, start) + or paren_end == -1 + or not kql_agg + ): + continue + + # Reuse existing aggregate or create new one + if kql_agg.lower() in existing_aggs: + ref_name = existing_aggs[kql_agg.lower()] + else: + agg_counter += 1 + clean_name = measure_name.strip('[]"') + ref_name = f'["__{clean_name}_{agg_counter}"]' + existing_aggs[kql_agg.lower()] = ref_name + new_aggregates.append((ref_name, kql_agg)) + + replacements.append((start, paren_end + 1, ref_name)) + + # Apply replacements from right to left so positions stay valid + result = expr + for start, end, ref_name in reversed(replacements): + result = result[:start] + ref_name + result[end:] + + return result, new_aggregates + + @staticmethod + def _contains_aggregate_function(expr: str) -> bool: + """Check if expression contains an aggregate function call.""" + for match in KQL_AGG_PATTERN.finditer(expr): + if not KustoKqlCompiler._is_inside_quotes_or_brackets(expr, match.start()): + return True + return False + def visit_select( self, select_stmt: selectable.Select, @@ -218,31 +363,71 @@ def _get_projection_or_summarize(self, select: selectable.Select) -> dict[str, s # | # N---> Add to projection if columns is not None: - summarize_columns = set() - extend_columns = set() + summarize_columns = [] + extend_columns = [] projection_columns = [] + # Track intermediary measures (should not appear in project) + intermediary_aliases = set() + # Track existing aggregates: kql_agg (lowercase) -> ref_name + # This allows reuse of already-defined aggregates + existing_aggs: dict[str, str] = {} + for column in [c for c in columns if c.name != "*"]: column_name, column_alias = self._extract_column_name_and_alias(column) column_alias = self._escape_and_quote_columns(column_alias, True) + column_name = re.sub( + r'(?:[a-zA-Z_][a-zA-Z0-9_]*|\["[^"]+"\])\.', "", column_name + ) # Remove table qualifiers from column name for processing # Do we have a group by clause ? # Do we have aggregate columns ? kql_agg = self._extract_maybe_agg_column_parts(column_name) - if kql_agg: + has_operators = self._has_operators_outside_quotes(column_name) + has_inline_aggregates = self._contains_aggregate_function(column_name) + + # Case 1: Simple aggregate (e.g., count(), sum(col)) + if kql_agg and not has_operators: has_aggregates = True - summarize_columns.add( - self._build_column_projection(kql_agg, column_alias) - ) - # No group by clause - # Do the columns have aliases ? - # Add additional and to handle case where : SELECT column_name as column_name - elif column_alias and column_alias != column_name: - extend_columns.add( - self._build_column_projection(column_name, column_alias, True) - ) - if column_alias: - projection_columns.append( - self._escape_and_quote_columns(column_alias, True) + summarize_entry = self._build_column_projection( + kql_agg, column_alias ) + if summarize_entry not in summarize_columns: + summarize_columns.append(summarize_entry) + projection_columns.append(column_alias) + # Register this aggregate for reuse by later columns + if column_alias: + existing_aggs[kql_agg.lower()] = column_alias + + # Case 2 & 3: Expressions with aggregates or aliased columns (both go to extend) + elif has_inline_aggregates or ( + column_alias + and column_alias != self._escape_and_quote_columns(column_name) + ): + # If contains aggregates, extract them first + if has_inline_aggregates: + has_aggregates = True + column_name, extracted_aggs = ( + self._extract_and_replace_aggregates( + column_name, column_alias or "expr", existing_aggs + ) + ) + + # Add extracted aggregates to summarize + for ref_name, kql_agg_extracted in extracted_aggs: + summarize_entry = self._build_column_projection( + kql_agg_extracted, ref_name + ) + if summarize_entry not in summarize_columns: + summarize_columns.append(summarize_entry) + intermediary_aliases.add(ref_name) + + # Build extend entry (common for both cases) + escaped_expr = self._escape_and_quote_columns(column_name) + extend_entry = f"{column_alias} = {escaped_expr}" + if extend_entry not in extend_columns: + extend_columns.append(extend_entry) + projection_columns.append(column_alias) + + # Case 4: Simple column reference else: projection_columns.append( self._escape_and_quote_columns(column_name) @@ -258,7 +443,12 @@ def _get_projection_or_summarize(self, select: selectable.Select) -> dict[str, s f"{summarize_statement} by {', '.join(by_columns)}" ) if extend_columns: - extend_statement = f"| extend {', '.join(sorted(extend_columns))}" + extend_statement = f"| extend {', '.join(extend_columns)}" + + # Filter out intermediary aliases from projection + projection_columns = [ + p for p in projection_columns if p not in intermediary_aliases + ] project_statement = ( f"| project {', '.join(projection_columns)}" if projection_columns @@ -389,11 +579,17 @@ def _escape_and_quote_columns(name: str | None, is_alias=False) -> str: def _find_operator_outside_quotes(text: str, operator: str) -> int: """Find position of operator that's not inside quoted strings. Returns -1 if not found.""" in_quotes = False + paren_depth = 0 for i, ch in enumerate(text): if ch == '"' and (i == 0 or text[i - 1] != "\\"): in_quotes = not in_quotes - elif ch == operator and not in_quotes: - return i + elif not in_quotes: + if ch == "(": + paren_depth += 1 + elif ch == ")": + paren_depth -= 1 + elif ch == operator and paren_depth == 0: + return i return -1 @staticmethod diff --git a/tests/unit/test_dialect_kql.py b/tests/unit/test_dialect_kql.py index d3e4917..c1bbaf4 100644 --- a/tests/unit/test_dialect_kql.py +++ b/tests/unit/test_dialect_kql.py @@ -173,12 +173,12 @@ def test_group_by_text(): query_compiled = str( query.compile(engine, compile_kwargs={"literal_binds": True}) ).replace("\n", "") - # raw query text from query + # raw query text from query - order matches column appearance query_expected = ( '["ActiveUsersLastMonth"]' '| summarize by ["EventInfo_Time"] / time(1d)' - '| extend ["ActiveUserMetric"] = ["ActiveUsers"], ' - '["EventInfo_Time"] = ["EventInfo_Time"] / time(1d)' + '| extend ["EventInfo_Time"] = ["EventInfo_Time"] / time(1d), ' + '["ActiveUserMetric"] = ["ActiveUsers"]' '| project ["EventInfo_Time"], ["ActiveUserMetric"]' '| order by ["ActiveUserMetric"] desc' ) @@ -202,12 +202,13 @@ def test_function_text(f: str, expected: str): query_compiled = str( query.compile(engine, compile_kwargs={"literal_binds": True}) ).replace("\n", "") + # Order matches column appearance in select query_expected = ( '["ActiveUsersLastMonth"]' - '| extend ["ActiveUserMetric"] = ["ActiveUsers"], ' - '["EventInfo_Time"] = ' + '| extend ["EventInfo_Time"] = ' + expected - + '| project ["EventInfo_Time"], ["ActiveUserMetric"]' + + ', ["ActiveUserMetric"] = ["ActiveUsers"]' + '| project ["EventInfo_Time"], ["ActiveUserMetric"]' ) assert query_compiled == query_expected @@ -215,6 +216,7 @@ def test_function_text(f: str, expected: str): def test_group_by_text_vaccine_dataset(): # SQL: SELECT country_name AS country_name FROM superset."CovidVaccineData" GROUP BY country_name # ORDER BY country_name ASC - this is a simple query to get distinct country names + # Note: When alias = column name, no extend is needed query = ( select([literal_column("country_name").label("country_name")]) .select_from(text('superset."CovidVaccineData"')) @@ -227,7 +229,6 @@ def test_group_by_text_vaccine_dataset(): query_expected = ( 'database("superset").["CovidVaccineData"]' '| summarize by ["country_name"]' - '| extend ["country_name"] = ["country_name"]' '| project ["country_name"]' '| order by ["country_name"] asc' ) @@ -567,7 +568,8 @@ def test_escape_and_quote_columns_preserves_already_bracketed(): def test_calculated_measure_with_two_adhoc_measures(): """Test calculated measure referencing two ad hoc measures. - Measure 3 = "Measure 1" + "Measure 2" should compile to ["Measure 1"] + ["Measure 2"] + Measure 3 = "Measure 1" + "Measure 2" should compile to (["Measure 1"]) + (["Measure 2"]) + Parentheses are added for arithmetic precedence clarity. """ measure_3 = literal_column('"Measure 1" + "Measure 2"').label("Measure 3") query = select([measure_3]).select_from(text("SalesData")) @@ -628,7 +630,7 @@ def test_calculated_measure_with_adhoc_measure_and_constant(): """Test calculated measure with an ad hoc measure and a constant. Measure 1 = count(*), Measure 2 = "Measure 1" * 2 - Measure 2 should compile to ["Measure 1"] * 2 + Measure 2 should compile to (["Measure 1"]) * 2 (parentheses for arithmetic precedence) """ measure_1 = literal_column("count(*)").label("Measure 1") measure_2 = literal_column('"Measure 1" * 2').label("Measure 2") @@ -649,7 +651,7 @@ def test_calculated_measure_with_two_adhoc_measures_and_aggregates(): """Test calculated measure referencing two ad hoc measures with aggregates. Measure 1 = count(*), Measure 2 = count(*) - Measure 3 = "Measure 1" + "Measure 2" should compile to ["Measure 1"] + ["Measure 2"] + Measure 3 = "Measure 1" + "Measure 2" should compile to (["Measure 1"]) + (["Measure 2"]) """ measure_1 = literal_column("count(*)").label("Measure 1") measure_2 = literal_column("count(*)").label("Measure 2") @@ -658,20 +660,315 @@ def test_calculated_measure_with_two_adhoc_measures_and_aggregates(): query_compiled = str( query.compile(engine, compile_kwargs={"literal_binds": True}) ).replace("\n", "") - # Summarize columns come from a set so order may vary - query_expected_1 = ( + query_expected = ( '["SalesData"]' '| summarize ["Measure 1"] = count(), ["Measure 2"] = count() ' '| extend ["Measure 3"] = ["Measure 1"] + ["Measure 2"]' '| project ["Measure 1"], ["Measure 2"], ["Measure 3"]' ) - query_expected_2 = ( + assert query_compiled == query_expected + + +def test_calculated_measure_with_inline_aggregates(): + """Test calculated measure with inline aggregates creating intermediary measures. + + Measure = count("a") + count("b") should create intermediary measures: + - __Measure_1 = count(["a"]) + - __Measure_2 = count(["b"]) + - Measure = ["__Measure_1"] + ["__Measure_2"] + """ + measure = literal_column('count("a") + count("b")').label("Measure") + query = select([measure]).select_from(text("SalesData")) + query_compiled = str( + query.compile(engine, compile_kwargs={"literal_binds": True}) + ).replace("\n", "") + query_expected = ( '["SalesData"]' - '| summarize ["Measure 2"] = count(), ["Measure 1"] = count() ' - '| extend ["Measure 3"] = ["Measure 1"] + ["Measure 2"]' - '| project ["Measure 1"], ["Measure 2"], ["Measure 3"]' + '| summarize ["__Measure_1"] = count(["a"]), ["__Measure_2"] = count(["b"]) ' + '| extend ["Measure"] = ["__Measure_1"] + ["__Measure_2"]' + '| project ["Measure"]' ) - assert query_compiled in (query_expected_1, query_expected_2) + assert query_compiled == query_expected + + +def test_calculated_measure_with_mixed_aggregates_and_references(): + """Test calculated measure mixing inline aggregate and predefined measure. + + Predefined 1 = count(*), Calculated = "Predefined 1" + count("b") + Should create: + - Predefined 1 = count() + - __Calculated_1 = count(["b"]) + - Calculated = ["Predefined 1"] + ["__Calculated_1"] + """ + predefined_1 = literal_column("count(*)").label("Predefined 1") + calculated = literal_column('"Predefined 1" + count("b")').label("Calculated") + query = select([predefined_1, calculated]).select_from(text("SalesData")) + query_compiled = str( + query.compile(engine, compile_kwargs={"literal_binds": True}) + ).replace("\n", "") + query_expected = ( + '["SalesData"]' + '| summarize ["Predefined 1"] = count(), ["__Calculated_1"] = count(["b"]) ' + '| extend ["Calculated"] = ["Predefined 1"] + ["__Calculated_1"]' + '| project ["Predefined 1"], ["Calculated"]' + ) + assert query_compiled == query_expected + + +# ============================================================================ +# Unit tests for helper functions +# ============================================================================ + + +class TestIsInsideQuotesOrBrackets: + """Tests for _is_inside_quotes_or_brackets helper.""" + + def test_position_inside_double_quotes(self): + text = 'before "inside" after' + # Positions inside the quotes (i, n, s, i, d, e) + assert KustoKqlCompiler._is_inside_quotes_or_brackets(text, 8) is True + assert KustoKqlCompiler._is_inside_quotes_or_brackets(text, 13) is True + + def test_position_outside_double_quotes(self): + text = 'before "inside" after' + assert KustoKqlCompiler._is_inside_quotes_or_brackets(text, 0) is False + assert KustoKqlCompiler._is_inside_quotes_or_brackets(text, 5) is False + assert KustoKqlCompiler._is_inside_quotes_or_brackets(text, 16) is False + + def test_position_inside_single_quotes(self): + text = "before 'inside' after" + assert KustoKqlCompiler._is_inside_quotes_or_brackets(text, 8) is True + assert KustoKqlCompiler._is_inside_quotes_or_brackets(text, 13) is True + + def test_position_outside_single_quotes(self): + text = "before 'inside' after" + assert KustoKqlCompiler._is_inside_quotes_or_brackets(text, 0) is False + assert KustoKqlCompiler._is_inside_quotes_or_brackets(text, 16) is False + + def test_position_inside_brackets(self): + text = 'before ["column"] after' + # Position inside the brackets + assert KustoKqlCompiler._is_inside_quotes_or_brackets(text, 8) is True + assert KustoKqlCompiler._is_inside_quotes_or_brackets(text, 15) is True + + def test_position_outside_brackets(self): + text = 'before ["column"] after' + assert KustoKqlCompiler._is_inside_quotes_or_brackets(text, 0) is False + assert KustoKqlCompiler._is_inside_quotes_or_brackets(text, 18) is False + + def test_nested_quotes_in_brackets(self): + text = '["col with \\"quotes\\""]' + # Position inside should be True + assert KustoKqlCompiler._is_inside_quotes_or_brackets(text, 5) is True + + def test_position_at_boundary(self): + text = '"text"' + assert ( + KustoKqlCompiler._is_inside_quotes_or_brackets(text, 0) is False + ) # At opening quote + assert ( + KustoKqlCompiler._is_inside_quotes_or_brackets(text, 1) is True + ) # After opening quote + + def test_out_of_bounds_position(self): + text = "short" + assert KustoKqlCompiler._is_inside_quotes_or_brackets(text, 100) is False + + +class TestFindMatchingParen: + """Tests for _find_matching_paren helper.""" + + def test_simple_parentheses(self): + text = "count(x)" + assert KustoKqlCompiler._find_matching_paren(text, 5) == 7 + + def test_nested_parentheses(self): + text = "sum(count(x))" + assert KustoKqlCompiler._find_matching_paren(text, 3) == 12 # Outer paren + assert KustoKqlCompiler._find_matching_paren(text, 9) == 11 # Inner paren + + def test_parentheses_with_quotes(self): + # Parens inside quotes should be ignored + text = 'func("text(with)parens")' + assert KustoKqlCompiler._find_matching_paren(text, 4) == 23 + + def test_parentheses_with_brackets(self): + # Parens inside brackets should be ignored + text = 'func(["col(1)"])' + assert KustoKqlCompiler._find_matching_paren(text, 4) == 15 + + def test_no_opening_paren_at_position(self): + text = "no paren here" + assert KustoKqlCompiler._find_matching_paren(text, 0) == -1 + + def test_unmatched_parenthesis(self): + text = "func(x" + assert KustoKqlCompiler._find_matching_paren(text, 4) == -1 + + def test_empty_parentheses(self): + text = "func()" + assert KustoKqlCompiler._find_matching_paren(text, 4) == 5 + + def test_deeply_nested(self): + text = "a(b(c(d)))" + assert KustoKqlCompiler._find_matching_paren(text, 1) == 9 + assert KustoKqlCompiler._find_matching_paren(text, 3) == 8 + assert KustoKqlCompiler._find_matching_paren(text, 5) == 7 + + +class TestHasOperatorsOutsideQuotes: + """Tests for _has_operators_outside_quotes helper.""" + + def test_simple_addition(self): + assert KustoKqlCompiler._has_operators_outside_quotes("a + b") is True + + def test_simple_subtraction(self): + assert KustoKqlCompiler._has_operators_outside_quotes("a - b") is True + + def test_simple_multiplication(self): + assert KustoKqlCompiler._has_operators_outside_quotes("a * b") is True + + def test_simple_division(self): + assert KustoKqlCompiler._has_operators_outside_quotes("a / b") is True + + def test_no_operators(self): + assert KustoKqlCompiler._has_operators_outside_quotes("count(x)") is False + + def test_operator_inside_quotes(self): + # Note: _find_operator_outside_quotes only tracks double quotes, not single + assert KustoKqlCompiler._has_operators_outside_quotes('"a + b"') is False + + def test_operator_inside_brackets(self): + assert KustoKqlCompiler._has_operators_outside_quotes('["a+b"]') is False + + def test_mixed_operators(self): + assert KustoKqlCompiler._has_operators_outside_quotes("a + b * c") is True + + def test_aggregate_with_operators(self): + assert ( + KustoKqlCompiler._has_operators_outside_quotes("count(a) + sum(b)") is True + ) + + +class TestExtractAndReplaceAggregates: + """Tests for _extract_and_replace_aggregates helper.""" + + def test_single_aggregate(self): + expr = 'count(["a"])' + result, new_aggs = KustoKqlCompiler._extract_and_replace_aggregates( + expr, "Measure" + ) + assert result == '["__Measure_1"]' + assert len(new_aggs) == 1 + assert new_aggs[0] == ('["__Measure_1"]', 'count(["a"])') + + def test_two_aggregates_with_operator(self): + expr = 'count(["a"]) + sum(["b"])' + result, new_aggs = KustoKqlCompiler._extract_and_replace_aggregates( + expr, "Measure" + ) + assert result == '["__Measure_1"] + ["__Measure_2"]' + assert len(new_aggs) == 2 + assert new_aggs[0][1] == 'count(["a"])' + assert new_aggs[1][1] == 'sum(["b"])' + + def test_no_aggregates(self): + expr = '["a"] + ["b"]' + result, new_aggs = KustoKqlCompiler._extract_and_replace_aggregates( + expr, "Measure" + ) + assert result == '["a"] + ["b"]' + assert len(new_aggs) == 0 + + def test_reuses_existing_aggregate(self): + expr = 'count(["a"]) + count(["a"])' + result, new_aggs = KustoKqlCompiler._extract_and_replace_aggregates( + expr, "Measure" + ) + # Both should use the same reference + assert result == '["__Measure_1"] + ["__Measure_1"]' + assert len(new_aggs) == 1 # Only one unique aggregate + + def test_existing_aggs_parameter(self): + existing = {'count(["a"])': '["existing_ref"]'} + expr = 'count(["a"]) + sum(["b"])' + result, new_aggs = KustoKqlCompiler._extract_and_replace_aggregates( + expr, "Measure", existing + ) + assert '["existing_ref"]' in result + assert len(new_aggs) == 1 # Only sum is new, count is reused + + def test_aggregate_in_quotes_ignored(self): + expr = '"count(a)"' + result, new_aggs = KustoKqlCompiler._extract_and_replace_aggregates( + expr, "Measure" + ) + assert result == '"count(a)"' + assert len(new_aggs) == 0 + + def test_aggregate_in_brackets_ignored(self): + expr = '["count(a)"]' + result, new_aggs = KustoKqlCompiler._extract_and_replace_aggregates( + expr, "Measure" + ) + assert result == '["count(a)"]' + assert len(new_aggs) == 0 + + def test_measure_name_with_special_chars(self): + expr = 'count(["a"])' + result, new_aggs = KustoKqlCompiler._extract_and_replace_aggregates( + expr, '["My Measure"]' + ) + assert result == '["__My Measure_1"]' + + def test_complex_expression(self): + expr = 'count(["a"]) * 100 / sum(["b"])' + result, new_aggs = KustoKqlCompiler._extract_and_replace_aggregates(expr, "Pct") + assert '["__Pct_1"]' in result + assert '["__Pct_2"]' in result + assert "* 100 /" in result + assert len(new_aggs) == 2 + + +class TestContainsAggregateFunction: + """Tests for _contains_aggregate_function helper.""" + + def test_contains_count(self): + assert KustoKqlCompiler._contains_aggregate_function("count(x)") is True + + def test_contains_sum(self): + assert KustoKqlCompiler._contains_aggregate_function("sum(x)") is True + + def test_contains_avg(self): + assert KustoKqlCompiler._contains_aggregate_function("avg(x)") is True + + def test_contains_min_max(self): + assert KustoKqlCompiler._contains_aggregate_function("min(x)") is True + assert KustoKqlCompiler._contains_aggregate_function("max(x)") is True + + def test_contains_dcount(self): + assert KustoKqlCompiler._contains_aggregate_function("dcount(x)") is True + + def test_no_aggregate(self): + assert KustoKqlCompiler._contains_aggregate_function("col + 1") is False + assert KustoKqlCompiler._contains_aggregate_function('["column"]') is False + + def test_aggregate_in_quotes_not_counted(self): + assert KustoKqlCompiler._contains_aggregate_function('"count(x)"') is False + assert KustoKqlCompiler._contains_aggregate_function("'sum(x)'") is False + + def test_aggregate_in_brackets_not_counted(self): + assert KustoKqlCompiler._contains_aggregate_function('["count(x)"]') is False + + def test_aggregate_with_complex_arg(self): + assert ( + KustoKqlCompiler._contains_aggregate_function('count(["My Col"])') is True + ) + + def test_multiple_aggregates(self): + assert ( + KustoKqlCompiler._contains_aggregate_function("count(a) + sum(b)") is True + ) @pytest.mark.parametrize( From 7b0ac2aa80429e71f6e19b8a0a225fe9a83e7bea Mon Sep 17 00:00:00 2001 From: Alison Gim Date: Tue, 10 Feb 2026 01:34:01 +0000 Subject: [PATCH 5/8] Fix parenthesized expression handling in _escape_and_quote_columns Prevents double-escaping of expressions like (count(a) + count(b)) by detecting matching outer parentheses, processing the inner content recursively, and re-adding the parentheses. --- sqlalchemy_kusto/dialect_kql.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/sqlalchemy_kusto/dialect_kql.py b/sqlalchemy_kusto/dialect_kql.py index ad8708b..103cfde 100644 --- a/sqlalchemy_kusto/dialect_kql.py +++ b/sqlalchemy_kusto/dialect_kql.py @@ -366,8 +366,6 @@ def _get_projection_or_summarize(self, select: selectable.Select) -> dict[str, s summarize_columns = [] extend_columns = [] projection_columns = [] - # Track intermediary measures (should not appear in project) - intermediary_aliases = set() # Track existing aggregates: kql_agg (lowercase) -> ref_name # This allows reuse of already-defined aggregates existing_aggs: dict[str, str] = {} @@ -418,7 +416,6 @@ def _get_projection_or_summarize(self, select: selectable.Select) -> dict[str, s ) if summarize_entry not in summarize_columns: summarize_columns.append(summarize_entry) - intermediary_aliases.add(ref_name) # Build extend entry (common for both cases) escaped_expr = self._escape_and_quote_columns(column_name) @@ -445,10 +442,6 @@ def _get_projection_or_summarize(self, select: selectable.Select) -> dict[str, s if extend_columns: extend_statement = f"| extend {', '.join(extend_columns)}" - # Filter out intermediary aliases from projection - projection_columns = [ - p for p in projection_columns if p not in intermediary_aliases - ] project_statement = ( f"| project {', '.join(projection_columns)}" if projection_columns From b90c808642faf511da83c67590f75fe56a26b251 Mon Sep 17 00:00:00 2001 From: Alison Gim Date: Tue, 10 Feb 2026 01:51:55 +0000 Subject: [PATCH 6/8] Simplify intermediary measures: remove paren depth tracking and duplicate checks --- sqlalchemy_kusto/dialect_kql.py | 26 +++++++++----------------- 1 file changed, 9 insertions(+), 17 deletions(-) diff --git a/sqlalchemy_kusto/dialect_kql.py b/sqlalchemy_kusto/dialect_kql.py index 103cfde..4c686e1 100644 --- a/sqlalchemy_kusto/dialect_kql.py +++ b/sqlalchemy_kusto/dialect_kql.py @@ -379,23 +379,23 @@ def _get_projection_or_summarize(self, select: selectable.Select) -> dict[str, s # Do we have a group by clause ? # Do we have aggregate columns ? kql_agg = self._extract_maybe_agg_column_parts(column_name) - has_operators = self._has_operators_outside_quotes(column_name) has_inline_aggregates = self._contains_aggregate_function(column_name) # Case 1: Simple aggregate (e.g., count(), sum(col)) - if kql_agg and not has_operators: + if kql_agg and not self._has_operators_outside_quotes(column_name): has_aggregates = True summarize_entry = self._build_column_projection( kql_agg, column_alias ) - if summarize_entry not in summarize_columns: - summarize_columns.append(summarize_entry) + summarize_columns.append(summarize_entry) projection_columns.append(column_alias) - # Register this aggregate for reuse by later columns if column_alias: existing_aggs[kql_agg.lower()] = column_alias # Case 2 & 3: Expressions with aggregates or aliased columns (both go to extend) + # No group by clause + # Do the columns have aliases ? + # Add additional and to handle case where : SELECT column_name as column_name elif has_inline_aggregates or ( column_alias and column_alias != self._escape_and_quote_columns(column_name) @@ -414,14 +414,12 @@ def _get_projection_or_summarize(self, select: selectable.Select) -> dict[str, s summarize_entry = self._build_column_projection( kql_agg_extracted, ref_name ) - if summarize_entry not in summarize_columns: - summarize_columns.append(summarize_entry) + summarize_columns.append(summarize_entry) # Build extend entry (common for both cases) escaped_expr = self._escape_and_quote_columns(column_name) extend_entry = f"{column_alias} = {escaped_expr}" - if extend_entry not in extend_columns: - extend_columns.append(extend_entry) + extend_columns.append(extend_entry) projection_columns.append(column_alias) # Case 4: Simple column reference @@ -572,17 +570,11 @@ def _escape_and_quote_columns(name: str | None, is_alias=False) -> str: def _find_operator_outside_quotes(text: str, operator: str) -> int: """Find position of operator that's not inside quoted strings. Returns -1 if not found.""" in_quotes = False - paren_depth = 0 for i, ch in enumerate(text): if ch == '"' and (i == 0 or text[i - 1] != "\\"): in_quotes = not in_quotes - elif not in_quotes: - if ch == "(": - paren_depth += 1 - elif ch == ")": - paren_depth -= 1 - elif ch == operator and paren_depth == 0: - return i + elif not in_quotes and ch == operator: + return i return -1 @staticmethod From 323c25798538b7b39e55fe794ebeb5bdb8127f1f Mon Sep 17 00:00:00 2001 From: Alison Gim Date: Tue, 10 Feb 2026 02:00:02 +0000 Subject: [PATCH 7/8] Remove comment about existing_aggs tracking --- sqlalchemy_kusto/dialect_kql.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/sqlalchemy_kusto/dialect_kql.py b/sqlalchemy_kusto/dialect_kql.py index 4c686e1..ea845dc 100644 --- a/sqlalchemy_kusto/dialect_kql.py +++ b/sqlalchemy_kusto/dialect_kql.py @@ -366,10 +366,7 @@ def _get_projection_or_summarize(self, select: selectable.Select) -> dict[str, s summarize_columns = [] extend_columns = [] projection_columns = [] - # Track existing aggregates: kql_agg (lowercase) -> ref_name - # This allows reuse of already-defined aggregates existing_aggs: dict[str, str] = {} - for column in [c for c in columns if c.name != "*"]: column_name, column_alias = self._extract_column_name_and_alias(column) column_alias = self._escape_and_quote_columns(column_alias, True) From b64cf115a5944a967bb84aacd021b60504a7d5f6 Mon Sep 17 00:00:00 2001 From: Alison Gim Date: Tue, 10 Feb 2026 04:54:43 +0000 Subject: [PATCH 8/8] Update tests for intermediary measures without paren depth tracking --- tests/unit/test_dialect_kql.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/tests/unit/test_dialect_kql.py b/tests/unit/test_dialect_kql.py index c1bbaf4..89692d3 100644 --- a/tests/unit/test_dialect_kql.py +++ b/tests/unit/test_dialect_kql.py @@ -429,7 +429,8 @@ def test_select_count(): 'let inner_qry = (["logs"]);' "inner_qry" "| where Field1 > 1 and Field2 < 2" - '| summarize ["total-count"] = count() ' + '| summarize ["__total-count_1"] = count() ' + '| extend ["total-count"] = ["__total-count_1"]' '| project ["total-count"]' '| order by ["total-count"] desc' "| take 5" @@ -640,8 +641,8 @@ def test_calculated_measure_with_adhoc_measure_and_constant(): ).replace("\n", "") query_expected = ( '["SalesData"]' - '| summarize ["Measure 1"] = count() ' - '| extend ["Measure 2"] = ["Measure 1"] * 2' + '| summarize ["__Measure 1_1"] = count() ' + '| extend ["Measure 1"] = ["__Measure 1_1"], ["Measure 2"] = ["Measure 1"] * 2' '| project ["Measure 1"], ["Measure 2"]' ) assert query_compiled == query_expected @@ -662,8 +663,8 @@ def test_calculated_measure_with_two_adhoc_measures_and_aggregates(): ).replace("\n", "") query_expected = ( '["SalesData"]' - '| summarize ["Measure 1"] = count(), ["Measure 2"] = count() ' - '| extend ["Measure 3"] = ["Measure 1"] + ["Measure 2"]' + '| summarize ["__Measure 1_1"] = count() ' + '| extend ["Measure 1"] = ["__Measure 1_1"], ["Measure 2"] = ["__Measure 1_1"], ["Measure 3"] = ["Measure 1"] + ["Measure 2"]' '| project ["Measure 1"], ["Measure 2"], ["Measure 3"]' ) assert query_compiled == query_expected @@ -708,8 +709,8 @@ def test_calculated_measure_with_mixed_aggregates_and_references(): ).replace("\n", "") query_expected = ( '["SalesData"]' - '| summarize ["Predefined 1"] = count(), ["__Calculated_1"] = count(["b"]) ' - '| extend ["Calculated"] = ["Predefined 1"] + ["__Calculated_1"]' + '| summarize ["__Predefined 1_1"] = count(), ["__Calculated_1"] = count(["b"]) ' + '| extend ["Predefined 1"] = ["__Predefined 1_1"], ["Calculated"] = ["Predefined 1"] + ["__Calculated_1"]' '| project ["Predefined 1"], ["Calculated"]' ) assert query_compiled == query_expected