diff --git a/sqlalchemy_kusto/dialect_kql.py b/sqlalchemy_kusto/dialect_kql.py index 5be6be3..ea845dc 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, @@ -107,7 +252,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)});" @@ -142,9 +287,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) @@ -212,31 +363,63 @@ 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 = [] + 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_inline_aggregates = self._contains_aggregate_function(column_name) + + # Case 1: Simple aggregate (e.g., count(), sum(col)) + if kql_agg and not self._has_operators_outside_quotes(column_name): has_aggregates = True - summarize_columns.add( - self._build_column_projection(kql_agg, column_alias) + summarize_entry = self._build_column_projection( + kql_agg, column_alias ) + summarize_columns.append(summarize_entry) + projection_columns.append(column_alias) + 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 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) - ) + 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 + ) + 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}" + 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) @@ -252,7 +435,8 @@ 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)}" + project_statement = ( f"| project {', '.join(projection_columns)}" if projection_columns @@ -356,29 +540,40 @@ 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) 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 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 not in_quotes and ch == operator: + return i + return -1 + @staticmethod def _sql_to_kql_where(where_clause: str) -> str: where_clause = where_clause.strip().replace("\n", "") @@ -547,7 +742,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..89692d3 100644 --- a/tests/unit/test_dialect_kql.py +++ b/tests/unit/test_dialect_kql.py @@ -173,11 +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"]| extend ["ActiveUserMetric"] = ["ActiveUsers"], ' - '["EventInfo_Time"] = ["EventInfo_Time"] / time(1d)' + '["ActiveUsersLastMonth"]' '| summarize by ["EventInfo_Time"] / time(1d)' + '| extend ["EventInfo_Time"] = ["EventInfo_Time"] / time(1d), ' + '["ActiveUserMetric"] = ["ActiveUsers"]' '| project ["EventInfo_Time"], ["ActiveUserMetric"]' '| order by ["ActiveUserMetric"] desc' ) @@ -201,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 @@ -214,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"')) @@ -224,20 +227,18 @@ 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"]' + '| 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 +329,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 +355,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' ) @@ -428,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" @@ -549,6 +551,427 @@ 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"]) + 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")) + 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 (parentheses for arithmetic precedence) + """ + 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_1"] = count() ' + '| extend ["Measure 1"] = ["__Measure 1_1"], ["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", "") + query_expected = ( + '["SalesData"]' + '| 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 + + +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_1"] = count(["a"]), ["__Measure_2"] = count(["b"]) ' + '| extend ["Measure"] = ["__Measure_1"] + ["__Measure_2"]' + '| project ["Measure"]' + ) + 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_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 + + +# ============================================================================ +# 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( ("query_table_name", "expected_table_name"), [