-
Notifications
You must be signed in to change notification settings - Fork 3
[Refactor] Basic Query Parser #90
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
Changes from 2 commits
43bf081
74ba863
fbbbbfc
0bb3113
d2ce8de
f95a659
7614b6c
a9c4214
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 | ||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,23 +1,354 @@ | ||||||||||||||||||||||||||||
| from core.ast.node import QueryNode | ||||||||||||||||||||||||||||
| from core.ast.node import ( | ||||||||||||||||||||||||||||
| Node, QueryNode, SelectNode, FromNode, WhereNode, TableNode, ColumnNode, | ||||||||||||||||||||||||||||
| LiteralNode, OperatorNode, FunctionNode, GroupByNode, HavingNode, | ||||||||||||||||||||||||||||
| OrderByNode, OrderByItemNode, LimitNode, OffsetNode, SubqueryNode, VarNode, VarSetNode, JoinNode | ||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||
| from core.ast.enums import JoinType, SortOrder | ||||||||||||||||||||||||||||
| import mo_sql_parsing as mosql | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| class QueryParser: | ||||||||||||||||||||||||||||
| @staticmethod | ||||||||||||||||||||||||||||
| def normalize_to_list(value): | ||||||||||||||||||||||||||||
| """Normalize mo_sql_parsing output to a list format. | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| mo_sql_parsing returns: | ||||||||||||||||||||||||||||
| - list when multiple items | ||||||||||||||||||||||||||||
| - dict when single item with structure | ||||||||||||||||||||||||||||
| - str when single simple value | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| This normalizes all cases to a list. | ||||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||||
| if value is None: | ||||||||||||||||||||||||||||
| return [] | ||||||||||||||||||||||||||||
| elif isinstance(value, list): | ||||||||||||||||||||||||||||
| return value | ||||||||||||||||||||||||||||
| elif isinstance(value, (dict, str)): | ||||||||||||||||||||||||||||
| return [value] | ||||||||||||||||||||||||||||
| else: | ||||||||||||||||||||||||||||
| return [value] | ||||||||||||||||||||||||||||
HazelYuAhiru marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| def parse(self, query: str) -> QueryNode: | ||||||||||||||||||||||||||||
| # Implement parsing logic using self.rules | ||||||||||||||||||||||||||||
| pass | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| # [1] Call mo_sql_parser | ||||||||||||||||||||||||||||
| # str -> Any (JSON) | ||||||||||||||||||||||||||||
| mosql_ast = mosql.parse(query) | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| # [2] Our new code | ||||||||||||||||||||||||||||
| # Any (JSON) -> AST (QueryNode) | ||||||||||||||||||||||||||||
| self.aliases = {} | ||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| select_clause = None | ||||||||||||||||||||||||||||
| from_clause = None | ||||||||||||||||||||||||||||
| where_clause = None | ||||||||||||||||||||||||||||
| group_by_clause = None | ||||||||||||||||||||||||||||
| having_clause = None | ||||||||||||||||||||||||||||
| order_by_clause = None | ||||||||||||||||||||||||||||
| limit_clause = None | ||||||||||||||||||||||||||||
| offset_clause = None | ||||||||||||||||||||||||||||
baiqiushi marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| if 'select' in mosql_ast: | ||||||||||||||||||||||||||||
| select_clause = self.parse_select(self.normalize_to_list(mosql_ast['select'])) | ||||||||||||||||||||||||||||
| if 'from' in mosql_ast: | ||||||||||||||||||||||||||||
| from_clause = self.parse_from(self.normalize_to_list(mosql_ast['from'])) | ||||||||||||||||||||||||||||
baiqiushi marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||||||||||||||||||||||||||||
| if 'where' in mosql_ast: | ||||||||||||||||||||||||||||
| where_clause = self.parse_where(mosql_ast['where']) | ||||||||||||||||||||||||||||
| if 'groupby' in mosql_ast: | ||||||||||||||||||||||||||||
| group_by_clause = self.parse_group_by(self.normalize_to_list(mosql_ast['groupby'])) | ||||||||||||||||||||||||||||
| if 'having' in mosql_ast: | ||||||||||||||||||||||||||||
baiqiushi marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||||||||||||||||||||||
| having_clause = self.parse_having(mosql_ast['having']) | ||||||||||||||||||||||||||||
| if 'orderby' in mosql_ast: | ||||||||||||||||||||||||||||
| order_by_clause = self.parse_order_by(self.normalize_to_list(mosql_ast['orderby'])) | ||||||||||||||||||||||||||||
| if 'limit' in mosql_ast: | ||||||||||||||||||||||||||||
| limit_clause = LimitNode(mosql_ast['limit']) | ||||||||||||||||||||||||||||
| if 'offset' in mosql_ast: | ||||||||||||||||||||||||||||
| offset_clause = OffsetNode(mosql_ast['offset']) | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| return QueryNode( | ||||||||||||||||||||||||||||
| _select=select_clause, | ||||||||||||||||||||||||||||
| _from=from_clause, | ||||||||||||||||||||||||||||
| _where=where_clause, | ||||||||||||||||||||||||||||
| _group_by=group_by_clause, | ||||||||||||||||||||||||||||
| _having=having_clause, | ||||||||||||||||||||||||||||
| _order_by=order_by_clause, | ||||||||||||||||||||||||||||
| _limit=limit_clause, | ||||||||||||||||||||||||||||
| _offset=offset_clause | ||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| def parse_select(self, select_list: list) -> SelectNode: | ||||||||||||||||||||||||||||
| items = set() | ||||||||||||||||||||||||||||
| for item in select_list: | ||||||||||||||||||||||||||||
| if isinstance(item, dict) and 'value' in item: | ||||||||||||||||||||||||||||
| expression = self.parse_expression(item['value']) | ||||||||||||||||||||||||||||
| # Handle alias - set for any node that has alias attribute | ||||||||||||||||||||||||||||
| if 'name' in item: | ||||||||||||||||||||||||||||
| alias = item['name'] | ||||||||||||||||||||||||||||
| if hasattr(expression, 'alias'): | ||||||||||||||||||||||||||||
| expression.alias = alias | ||||||||||||||||||||||||||||
| self.aliases[alias] = expression | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| items.add(expression) | ||||||||||||||||||||||||||||
| else: | ||||||||||||||||||||||||||||
| # Handle direct expression (string, int, etc.) | ||||||||||||||||||||||||||||
| expression = self.parse_expression(item) | ||||||||||||||||||||||||||||
| items.add(expression) | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| return SelectNode(items) | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| def parse_from(self, from_list: list) -> FromNode: | ||||||||||||||||||||||||||||
| sources = set() | ||||||||||||||||||||||||||||
| left_source = None # Can be a table or the result of a previous join | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| for item in from_list: | ||||||||||||||||||||||||||||
| # Check for JOIN first (before checking for 'value') | ||||||||||||||||||||||||||||
| if isinstance(item, dict): | ||||||||||||||||||||||||||||
| # Look for any join key | ||||||||||||||||||||||||||||
| join_key = next((k for k in item.keys() if 'join' in k.lower()), None) | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| if join_key: | ||||||||||||||||||||||||||||
| # This is a JOIN | ||||||||||||||||||||||||||||
| if left_source is None: | ||||||||||||||||||||||||||||
| raise ValueError("JOIN found without a left table") | ||||||||||||||||||||||||||||
HazelYuAhiru marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| join_info = item[join_key] | ||||||||||||||||||||||||||||
| # Handle both string and dict join_info | ||||||||||||||||||||||||||||
| if isinstance(join_info, str): | ||||||||||||||||||||||||||||
| table_name = join_info | ||||||||||||||||||||||||||||
| alias = None | ||||||||||||||||||||||||||||
| else: | ||||||||||||||||||||||||||||
| table_name = join_info['value'] if isinstance(join_info, dict) else join_info | ||||||||||||||||||||||||||||
| alias = join_info.get('name') if isinstance(join_info, dict) else None | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| right_table = TableNode(table_name, alias) | ||||||||||||||||||||||||||||
| # Track table alias | ||||||||||||||||||||||||||||
| if alias: | ||||||||||||||||||||||||||||
| self.aliases[alias] = right_table | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| on_condition = None | ||||||||||||||||||||||||||||
| if 'on' in item: | ||||||||||||||||||||||||||||
| on_condition = self.parse_expression(item['on']) | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| # Create join node - left_source might be a table or a previous join | ||||||||||||||||||||||||||||
| join_type = self.parse_join_type(join_key) | ||||||||||||||||||||||||||||
| join_node = JoinNode(left_source, right_table, join_type, on_condition) | ||||||||||||||||||||||||||||
| # The result of this JOIN becomes the new left source for potential next JOIN | ||||||||||||||||||||||||||||
| left_source = join_node | ||||||||||||||||||||||||||||
HazelYuAhiru marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||||||||||||||||||||||
| elif 'value' in item: | ||||||||||||||||||||||||||||
| # This is a table reference | ||||||||||||||||||||||||||||
| table_name = item['value'] | ||||||||||||||||||||||||||||
| alias = item.get('name') | ||||||||||||||||||||||||||||
| table_node = TableNode(table_name, alias) | ||||||||||||||||||||||||||||
| # Track table alias | ||||||||||||||||||||||||||||
| if alias: | ||||||||||||||||||||||||||||
| self.aliases[alias] = table_node | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| if left_source is None: | ||||||||||||||||||||||||||||
| # First table becomes the left source | ||||||||||||||||||||||||||||
| left_source = table_node | ||||||||||||||||||||||||||||
| else: | ||||||||||||||||||||||||||||
| # Multiple tables without explicit JOIN (cross join) | ||||||||||||||||||||||||||||
| sources.add(table_node) | ||||||||||||||||||||||||||||
| elif isinstance(item, str): | ||||||||||||||||||||||||||||
| # Simple string table name | ||||||||||||||||||||||||||||
| table_node = TableNode(item) | ||||||||||||||||||||||||||||
| if left_source is None: | ||||||||||||||||||||||||||||
| left_source = table_node | ||||||||||||||||||||||||||||
| else: | ||||||||||||||||||||||||||||
| sources.add(table_node) | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| # Add the final left source (which might be a single table or chain of joins) | ||||||||||||||||||||||||||||
| if left_source is not None: | ||||||||||||||||||||||||||||
| sources.add(left_source) | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| return FromNode(sources) | ||||||||||||||||||||||||||||
HazelYuAhiru marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| def parse_where(self, where_dict: dict) -> WhereNode: | ||||||||||||||||||||||||||||
| predicates = set() | ||||||||||||||||||||||||||||
| predicates.add(self.parse_expression(where_dict)) | ||||||||||||||||||||||||||||
| return WhereNode(predicates) | ||||||||||||||||||||||||||||
HazelYuAhiru marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| def parse_group_by(self, group_by_list: list) -> GroupByNode: | ||||||||||||||||||||||||||||
| items = [] | ||||||||||||||||||||||||||||
| for item in group_by_list: | ||||||||||||||||||||||||||||
| if isinstance(item, dict) and 'value' in item: | ||||||||||||||||||||||||||||
| expr = self.parse_expression(item['value']) | ||||||||||||||||||||||||||||
| # Resolve aliases | ||||||||||||||||||||||||||||
| expr = self.resolve_aliases(expr) | ||||||||||||||||||||||||||||
| items.append(expr) | ||||||||||||||||||||||||||||
| else: | ||||||||||||||||||||||||||||
| # Handle direct expression (string, int, etc.) | ||||||||||||||||||||||||||||
| expr = self.parse_expression(item) | ||||||||||||||||||||||||||||
| expr = self.resolve_aliases(expr) | ||||||||||||||||||||||||||||
| items.append(expr) | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| def format(self, query: QueryNode) -> str: | ||||||||||||||||||||||||||||
| # Implement formatting logic to convert AST back to SQL string | ||||||||||||||||||||||||||||
| pass | ||||||||||||||||||||||||||||
| return GroupByNode(items) | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| def parse_having(self, having_dict: dict) -> HavingNode: | ||||||||||||||||||||||||||||
| predicates = set() | ||||||||||||||||||||||||||||
| expr = self.parse_expression(having_dict) | ||||||||||||||||||||||||||||
| # Check if this expression references an aliased function from SELECT | ||||||||||||||||||||||||||||
| expr = self.resolve_aliases(expr) | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| predicates.add(expr) | ||||||||||||||||||||||||||||
HazelYuAhiru marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| # [1] Our new code | ||||||||||||||||||||||||||||
| # AST (QueryNode) -> JSON | ||||||||||||||||||||||||||||
| return HavingNode(predicates) | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| def parse_order_by(self, order_by_list: list) -> OrderByNode: | ||||||||||||||||||||||||||||
| items = [] | ||||||||||||||||||||||||||||
| for item in order_by_list: | ||||||||||||||||||||||||||||
| if isinstance(item, dict) and 'value' in item: | ||||||||||||||||||||||||||||
| value = item['value'] | ||||||||||||||||||||||||||||
| # Check if this is an alias reference | ||||||||||||||||||||||||||||
| if isinstance(value, str) and value in self.aliases: | ||||||||||||||||||||||||||||
| column = self.aliases[value] | ||||||||||||||||||||||||||||
| else: | ||||||||||||||||||||||||||||
| # Parse normally for other cases | ||||||||||||||||||||||||||||
| column = self.parse_expression(value) | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| # Get sort order (default is ASC) | ||||||||||||||||||||||||||||
| sort_order = SortOrder.ASC | ||||||||||||||||||||||||||||
| if 'sort' in item: | ||||||||||||||||||||||||||||
| sort_str = item['sort'].upper() | ||||||||||||||||||||||||||||
| if sort_str == 'DESC': | ||||||||||||||||||||||||||||
| sort_order = SortOrder.DESC | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| # Wrap in OrderByItemNode | ||||||||||||||||||||||||||||
| order_by_item = OrderByItemNode(column, sort_order) | ||||||||||||||||||||||||||||
| items.append(order_by_item) | ||||||||||||||||||||||||||||
| else: | ||||||||||||||||||||||||||||
| # Handle direct expression (string, int, etc.) | ||||||||||||||||||||||||||||
| column = self.parse_expression(item) | ||||||||||||||||||||||||||||
| order_by_item = OrderByItemNode(column, SortOrder.ASC) | ||||||||||||||||||||||||||||
| items.append(order_by_item) | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| # [2] Call mo_sql_format | ||||||||||||||||||||||||||||
| # Any (JSON) -> str | ||||||||||||||||||||||||||||
| return OrderByNode(items) | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| def resolve_aliases(self, expr: Node) -> Node: | ||||||||||||||||||||||||||||
| if isinstance(expr, OperatorNode): | ||||||||||||||||||||||||||||
| # Recursively resolve aliases in operator operands | ||||||||||||||||||||||||||||
| left = self.resolve_aliases(expr.children[0]) | ||||||||||||||||||||||||||||
| right = self.resolve_aliases(expr.children[1]) | ||||||||||||||||||||||||||||
| return OperatorNode(left, expr.name, right) | ||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||
| left = self.resolve_aliases(expr.children[0]) | |
| right = self.resolve_aliases(expr.children[1]) | |
| return OperatorNode(left, expr.name, right) | |
| if len(expr.children) == 1: | |
| child = self.resolve_aliases(expr.children[0]) | |
| return OperatorNode(child, expr.name) | |
| elif len(expr.children) == 2: | |
| left = self.resolve_aliases(expr.children[0]) | |
| right = self.resolve_aliases(expr.children[1]) | |
| return OperatorNode(left, expr.name, right) | |
| else: | |
| # Unexpected number of children; return as is | |
| return expr |
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.
This is a good point. But I doubt if we can resolve it in this PR since we don't have such test cases. Let's push this fix to later PRs where we introduce more test cases.
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.
Got it, I added a TODO in this section as a reminder.
Outdated
Copilot
AI
Dec 4, 2025
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.
[nitpick] The alias resolution in the HAVING clause iterates through all aliases to find matching FunctionNode or ColumnNode instances (lines 233-241, 246-252). For large SELECT clauses with many aliases, this could be inefficient. Consider optimizing by creating a separate lookup structure or only checking relevant aliases.
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.
That is OK for now.
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.
Import of 'SubqueryNode' is not used.
Import of 'VarNode' is not used.
Import of 'VarSetNode' is not used.
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.
I also noticed this version of implementation does not consider subquery. We will consider it in the next iteration.
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.
Got it, in this case I will leave a TODO comment above as reminders.