"""Identifier rewriting that lets model SQL use human-friendly names. The model often emits table/column names with spaces, original casing, or the raw workbook/sheet labels. I rewrite those into the normalized identifiers I register in DuckDB so the statement actually runs. Author: mohamedgamal04 """ from __future__ import annotations import re from typing import TYPE_CHECKING if TYPE_CHECKING: from .registry import SqlRewriteContext def _canonical_identifier(value: str) -> str: """Return lowercase alphanumeric-only identifier for tolerant matching.""" return re.sub(r"[^0-9A-Za-z]+", "", value).lower() def _normalize_sql_statement(statement: str) -> str: """Normalize SQL quoting style for DuckDB parsing.""" return statement.replace("`", '"') def _strip_identifier_quotes(identifier: str) -> str: """Strip one layer of common identifier quotes.""" cleaned = identifier.strip() if cleaned[:1] in {'`', '"', "'", '['} and cleaned[-1:] in {'`', '"', "'", ']'}: return cleaned[1:-1] return cleaned def _normalize_single_quoted_table_identifiers(statement: str, table_identifiers: set[str]) -> str: """Convert single-quoted table identifiers to double-quoted table identifiers.""" def _replace(match: re.Match[str]) -> str: keyword = match.group(1) identifier = match.group(2) if identifier in table_identifiers: return f'{keyword} "{identifier}"' return match.group(0) pattern = re.compile(r"\b(from|update|into|table|join)\s+'([^']+)'", flags=re.IGNORECASE) return pattern.sub(_replace, statement) def _rewrite_to_normalized_identifiers( statement: str, table_name_map: dict[str, str], table_alias_map: dict[str, str], column_name_map: dict[str, str], column_alias_map: dict[str, str], ) -> str: """Rewrite known table/column variants to normalized identifiers.""" rewritten = statement for original_name, normalized_name in sorted(table_name_map.items(), key=lambda item: len(item[0]), reverse=True): rewritten = re.sub(rf'"{re.escape(original_name)}"', normalized_name, rewritten) rewritten = re.sub(rf"'{re.escape(original_name)}'", normalized_name, rewritten) if " " not in original_name: rewritten = re.sub(rf"\b{re.escape(original_name)}\b", normalized_name, rewritten) def _replace_table_alias(match: re.Match[str]) -> str: keyword = match.group(1) table_token = match.group(2) normalized_name = table_alias_map.get(_canonical_identifier(table_token)) if normalized_name: return f"{keyword} {normalized_name}" return match.group(0) rewritten = re.sub( r"\b(from|update|into|table|join)\s+([A-Za-z_][A-Za-z0-9_]*)", _replace_table_alias, rewritten, flags=re.IGNORECASE, ) for original_name, normalized_name in sorted(column_name_map.items(), key=lambda item: len(item[0]), reverse=True): rewritten = re.sub(rf'"{re.escape(original_name)}"', normalized_name, rewritten) rewritten = re.sub(rf"'{re.escape(original_name)}'", normalized_name, rewritten) def _replace_qualified_column(match: re.Match[str]) -> str: table_or_alias = match.group(1) column_token = match.group(2) normalized_name = column_alias_map.get(_canonical_identifier(column_token)) if normalized_name: return f"{table_or_alias}.{normalized_name}" return match.group(0) rewritten = re.sub(r'\b([A-Za-z_][A-Za-z0-9_]*)\s*\.\s*"([^"]+)"', _replace_qualified_column, rewritten) rewritten = re.sub(r"\b([A-Za-z_][A-Za-z0-9_]*)\s*\.\s*'([^']+)'", _replace_qualified_column, rewritten) rewritten = re.sub(r"\b([A-Za-z_][A-Za-z0-9_]*)\s*\.\s*([A-Za-z_][A-Za-z0-9_]*)", _replace_qualified_column, rewritten) return rewritten def _quote_known_identifiers(statement: str, identifiers: set[str]) -> str: """Quote known identifiers with spaces so DuckDB parses them correctly.""" rewritten = statement for identifier in sorted(identifiers, key=len, reverse=True): if not identifier or " " not in identifier: continue pattern = re.compile(rf'(? str: """Normalize and rewrite model SQL to table/column identifiers known by DuckDB.""" rewritten = _normalize_sql_statement(statement) rewritten = _rewrite_to_normalized_identifiers( rewritten, rewrite_context["table_name_map"], rewrite_context["table_alias_map"], rewrite_context["column_name_map"], rewrite_context["column_alias_map"], ) rewritten = _normalize_single_quoted_table_identifiers(rewritten, rewrite_context["table_identifiers"]) rewritten = _quote_known_identifiers(rewritten, rewrite_context["table_identifiers"]) rewritten = _quote_known_identifiers(rewritten, rewrite_context["column_identifiers"]) # A second pass ensures identifiers quoted in the previous step still map # to normalized DuckDB base tables (not compatibility views). rewritten = _rewrite_to_normalized_identifiers( rewritten, rewrite_context["table_name_map"], rewrite_context["table_alias_map"], rewrite_context["column_name_map"], rewrite_context["column_alias_map"], ) return rewritten