-
Notifications
You must be signed in to change notification settings - Fork 12
Support parentheses for calculated measures #52
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
5548c47
062cf90
fdd3851
1246f27
7b0ac2a
b90c808
323c257
b64cf11
d6cc4e6
51e16e8
98da2eb
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -66,6 +66,58 @@ | |||||||||||||||||||||||||||||||||||||||||||||||||
| "variancep", | ||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||
| AGGREGATE_PATTERN = r"(\w+)\s*\(\s*(DISTINCT|distinct\s*)?\(?\s*(\*|\[?\"?\'?\w+\"?\]?)\s*(,.+)*\)?\s*\)" | ||||||||||||||||||||||||||||||||||||||||||||||||||
| # Pre-compiled regex for aggregate function matching (performance optimization). | ||||||||||||||||||||||||||||||||||||||||||||||||||
| # Compiled once at module load to avoid recompiling on every call, which significantly | ||||||||||||||||||||||||||||||||||||||||||||||||||
| # improves performance for query-heavy workloads. | ||||||||||||||||||||||||||||||||||||||||||||||||||
| KQL_AGG_PATTERN = re.compile( | ||||||||||||||||||||||||||||||||||||||||||||||||||
| r"\b(" + "|".join(kql_aggregates) + r")\s*\(", re.IGNORECASE | ||||||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| class _ParseState: | ||||||||||||||||||||||||||||||||||||||||||||||||||
| """Tracks parsing state while scanning through text.""" | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| __slots__ = ("in_double_quote", "in_single_quote", "in_bracket", "paren_depth") | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| def __init__(self): | ||||||||||||||||||||||||||||||||||||||||||||||||||
| self.in_double_quote = False | ||||||||||||||||||||||||||||||||||||||||||||||||||
| self.in_single_quote = False | ||||||||||||||||||||||||||||||||||||||||||||||||||
| self.in_bracket = False | ||||||||||||||||||||||||||||||||||||||||||||||||||
| self.paren_depth = 0 | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| def update(self, ch: str, prev_ch: str | None) -> None: | ||||||||||||||||||||||||||||||||||||||||||||||||||
| """Update state based on current and previous character.""" | ||||||||||||||||||||||||||||||||||||||||||||||||||
| # Handle quotes (only if not escaped and not in conflicting context) | ||||||||||||||||||||||||||||||||||||||||||||||||||
| if ( | ||||||||||||||||||||||||||||||||||||||||||||||||||
| ch == '"' | ||||||||||||||||||||||||||||||||||||||||||||||||||
| and prev_ch != "\\" | ||||||||||||||||||||||||||||||||||||||||||||||||||
| and not self.in_single_quote | ||||||||||||||||||||||||||||||||||||||||||||||||||
| and not self.in_bracket | ||||||||||||||||||||||||||||||||||||||||||||||||||
| ): | ||||||||||||||||||||||||||||||||||||||||||||||||||
| self.in_double_quote = not self.in_double_quote | ||||||||||||||||||||||||||||||||||||||||||||||||||
| elif ( | ||||||||||||||||||||||||||||||||||||||||||||||||||
| ch == "'" | ||||||||||||||||||||||||||||||||||||||||||||||||||
| and prev_ch != "\\" | ||||||||||||||||||||||||||||||||||||||||||||||||||
| and not self.in_double_quote | ||||||||||||||||||||||||||||||||||||||||||||||||||
| and not self.in_bracket | ||||||||||||||||||||||||||||||||||||||||||||||||||
| ): | ||||||||||||||||||||||||||||||||||||||||||||||||||
| self.in_single_quote = not self.in_single_quote | ||||||||||||||||||||||||||||||||||||||||||||||||||
| # Handle brackets and parens (only if not in quotes) | ||||||||||||||||||||||||||||||||||||||||||||||||||
| elif not self.in_double_quote and not self.in_single_quote: | ||||||||||||||||||||||||||||||||||||||||||||||||||
| if ch == "[": | ||||||||||||||||||||||||||||||||||||||||||||||||||
| self.in_bracket = True | ||||||||||||||||||||||||||||||||||||||||||||||||||
| elif ch == "]": | ||||||||||||||||||||||||||||||||||||||||||||||||||
| self.in_bracket = False | ||||||||||||||||||||||||||||||||||||||||||||||||||
| elif not self.in_bracket: | ||||||||||||||||||||||||||||||||||||||||||||||||||
| if ch == "(": | ||||||||||||||||||||||||||||||||||||||||||||||||||
| self.paren_depth += 1 | ||||||||||||||||||||||||||||||||||||||||||||||||||
| elif ch == ")": | ||||||||||||||||||||||||||||||||||||||||||||||||||
| self.paren_depth -= 1 | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| @property | ||||||||||||||||||||||||||||||||||||||||||||||||||
| def in_quotes_or_brackets(self) -> bool: | ||||||||||||||||||||||||||||||||||||||||||||||||||
| """Check if currently inside quotes or brackets.""" | ||||||||||||||||||||||||||||||||||||||||||||||||||
| return self.in_double_quote or self.in_single_quote or self.in_bracket | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| class UniversalSet: | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -89,6 +141,149 @@ class KustoKqlCompiler(compiler.SQLCompiler): | |||||||||||||||||||||||||||||||||||||||||||||||||
| visit_sequence = None | ||||||||||||||||||||||||||||||||||||||||||||||||||
| sort_with_clause_parts = 2 | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| @staticmethod | ||||||||||||||||||||||||||||||||||||||||||||||||||
| def _find_top_level_operator(text: str, operator: str) -> int: | ||||||||||||||||||||||||||||||||||||||||||||||||||
| """Find position of operator at depth 0 (not inside quotes, brackets, or parens). | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| Args: | ||||||||||||||||||||||||||||||||||||||||||||||||||
| text: The string to search in | ||||||||||||||||||||||||||||||||||||||||||||||||||
| operator: The single-character operator to find (e.g., '+', '-', '*', '/') | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| Returns: | ||||||||||||||||||||||||||||||||||||||||||||||||||
| The position of the operator at depth 0, or -1 if not found. | ||||||||||||||||||||||||||||||||||||||||||||||||||
| Returns -1 when the operator only appears inside quotes, brackets, or nested parens. | ||||||||||||||||||||||||||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||||||||||||||||||||||||||
| state = _ParseState() | ||||||||||||||||||||||||||||||||||||||||||||||||||
| for i, ch in enumerate(text): | ||||||||||||||||||||||||||||||||||||||||||||||||||
| if ( | ||||||||||||||||||||||||||||||||||||||||||||||||||
| ch == operator | ||||||||||||||||||||||||||||||||||||||||||||||||||
| and state.paren_depth == 0 | ||||||||||||||||||||||||||||||||||||||||||||||||||
| and not state.in_quotes_or_brackets | ||||||||||||||||||||||||||||||||||||||||||||||||||
| ): | ||||||||||||||||||||||||||||||||||||||||||||||||||
| return i | ||||||||||||||||||||||||||||||||||||||||||||||||||
| state.update(ch, text[i - 1] if i > 0 else None) | ||||||||||||||||||||||||||||||||||||||||||||||||||
| return -1 | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| @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 | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| state = _ParseState() | ||||||||||||||||||||||||||||||||||||||||||||||||||
| for i in range(pos): | ||||||||||||||||||||||||||||||||||||||||||||||||||
| state.update(text[i], text[i - 1] if i > 0 else None) | ||||||||||||||||||||||||||||||||||||||||||||||||||
| return state.in_quotes_or_brackets | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| @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 | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| state = _ParseState() | ||||||||||||||||||||||||||||||||||||||||||||||||||
| state.paren_depth = 1 # Start with depth 1 since we're at opening paren | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| for i in range(start_pos + 1, len(text)): | ||||||||||||||||||||||||||||||||||||||||||||||||||
| ch = text[i] | ||||||||||||||||||||||||||||||||||||||||||||||||||
| state.update(ch, text[i - 1] if i > 0 else None) | ||||||||||||||||||||||||||||||||||||||||||||||||||
| if state.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 and brackets.""" | ||||||||||||||||||||||||||||||||||||||||||||||||||
| return any( | ||||||||||||||||||||||||||||||||||||||||||||||||||
| KustoKqlCompiler._find_top_level_operator(expr, op) != -1 for op in "+-*/" | ||||||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| @staticmethod | ||||||||||||||||||||||||||||||||||||||||||||||||||
| def _count_outer_parens(text: str) -> tuple[int, str]: | ||||||||||||||||||||||||||||||||||||||||||||||||||
| """Count and strip outer parentheses from text. Returns (count, stripped_text).""" | ||||||||||||||||||||||||||||||||||||||||||||||||||
| text = text.strip() | ||||||||||||||||||||||||||||||||||||||||||||||||||
| count = 0 | ||||||||||||||||||||||||||||||||||||||||||||||||||
| while len(text) > 1 and text[0] == "(" and text[-1] == ")": | ||||||||||||||||||||||||||||||||||||||||||||||||||
| depth = 0 | ||||||||||||||||||||||||||||||||||||||||||||||||||
| for ch in text[:-1]: # Scan all but last char | ||||||||||||||||||||||||||||||||||||||||||||||||||
| depth += (ch == "(") - (ch == ")") | ||||||||||||||||||||||||||||||||||||||||||||||||||
| if depth == 0: | ||||||||||||||||||||||||||||||||||||||||||||||||||
| return count, text # First '(' closed before end | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+203
to
+211
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| """Count and strip outer parentheses from text. Returns (count, stripped_text).""" | |
| text = text.strip() | |
| count = 0 | |
| while len(text) > 1 and text[0] == "(" and text[-1] == ")": | |
| depth = 0 | |
| for ch in text[:-1]: # Scan all but last char | |
| depth += (ch == "(") - (ch == ")") | |
| if depth == 0: | |
| return count, text # First '(' closed before end | |
| """Count and strip outer parentheses from text. Returns (count, stripped_text). | |
| This respects quoted strings and brackets by using the parser-aware | |
| `_find_matching_paren` helper, which relies on `_ParseState`. | |
| """ | |
| text = text.strip() | |
| count = 0 | |
| # Repeatedly strip a single layer of outer parentheses as long as the | |
| # opening parenthesis at position 0 matches the final character. | |
| while len(text) > 1 and text[0] == "(": | |
| match_idx = KustoKqlCompiler._find_matching_paren(text, 0) | |
| # If we didn't find a matching paren, or it doesn't close at the end, | |
| # then the outer '(' does not wrap the whole expression. | |
| if match_idx != len(text) - 1: | |
| break |
Copilot
AI
Feb 11, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The change from sets to lists for summarize_columns and extend_columns (lines 416-417) removes automatic deduplication. This means if the same column definition is added multiple times through the logic, it will appear multiple times in the final query, which could cause KQL syntax errors or unexpected behavior.
Consider adding explicit deduplication logic if needed, or document why duplicate entries are not possible in this context.
Copilot
AI
Feb 11, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The change from sorted(extend_columns) to just extend_columns removes the deterministic ordering of extend columns. While the new behavior preserves the order in which columns appear in the SELECT clause (which is generally more intuitive), this is a breaking change in query output format that could affect users who depend on alphabetical ordering.
Consider documenting this behavior change explicitly in the PR description or release notes, as it may impact query caching, testing, or tools that parse the generated KQL queries.
| extend_statement = f"| extend {', '.join(extend_columns)}" | |
| extend_statement = f"| extend {', '.join(sorted(extend_columns))}" |
Copilot
AI
Feb 11, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The operator parsing order ["/", "+", "-", "*"] (line 599) doesn't respect standard mathematical operator precedence. In mathematics, multiplication and division have higher precedence than addition and subtraction, so a + b * c should be parsed as a + (b * c), not (a + b) * c.
The current implementation would split "col_a" + "col_b" * 2 at the / first (not found), then at +, producing ["col_a"] + ["col_b"] * 2, which happens to be correct by accident. However, for an expression like "a" / "b" + "c", it would split at / first, producing ["a"] / ["b"] + "c", which would then not split the + "c" part correctly on the right side.
To fix this, search for lower-precedence operators first. Change the order to ["+", "-", "*", "/"] or better yet ["+", "-"] then ["*", "/"] in separate passes. Alternatively, note that the current behavior might be intentional if all operators are meant to be treated with equal precedence in KQL contexts - in that case, please add a comment explaining this design decision.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The
_ParseStateclass tracks bracket state as a simple boolean (in_bracket), which assumes brackets are never nested. However, KQL bracket notation["column"]could theoretically contain nested structures or be part of more complex expressions. While this is unlikely in practice for KQL column references, the implementation is inconsistent with how parentheses are tracked (using a depth counter).Consider whether nested brackets are possible in your use case. If they are, implement a depth counter similar to
paren_depthfor brackets as well.