File size: 7,016 Bytes
0cac9cf
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
"""Load Excel workbooks into DuckDB and build the identifier rewrite context.

Every sheet of every workbook becomes its own `workbook__sheet` table in a
single DuckDB connection, so reads (including JOINs across sheets) see all of
them at once. I also collect the name mappings the rewrite layer needs.

Author: mohamedgamal04
"""

from __future__ import annotations

import re
from pathlib import Path
from typing import TypedDict

import duckdb
import pandas as pd

from ..config import EXCEL_DIR
from ..excel.context import list_excel_files
from .rewrite import _canonical_identifier


class WorkbookRecord(TypedDict):
    """Workbook sheet metadata used for optional save-back after DML."""

    file_path: Path
    table_name: str
    sheet_name: str


class SqlRewriteContext(TypedDict):
    """Identifier mappings used to tolerate human-friendly SQL names."""

    table_identifiers: set[str]
    column_identifiers: set[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]


def _table_name_from_file(file_name: str) -> str:
    """Normalize a workbook filename to a SQL-safe table name."""
    stem = file_name.rsplit(".", 1)[0]
    normalized = re.sub(r"[^0-9A-Za-z]+", "_", stem).strip("_")
    return normalized or "excel_data"


def _table_name_from_sheet(file_name: str, sheet_name: str) -> str:
    """Normalize workbook + sheet names into a SQL-safe table identifier."""
    workbook_part = _table_name_from_file(file_name)
    sheet_part = _table_name_from_file(sheet_name)
    return f"{workbook_part}__{sheet_part}"


def _column_name_from_value(value: object) -> str:
    """Normalize a column label by replacing whitespace with underscores."""
    text = str(value).strip()
    normalized = re.sub(r"\s+", "_", text)
    normalized = re.sub(r"_+", "_", normalized).strip("_")
    return normalized or "column"


def _normalize_columns(df: pd.DataFrame) -> pd.DataFrame:
    """Return DataFrame copy with normalized and unique column names using df.rename."""
    rename_map: dict[object, str] = {}
    seen: dict[str, int] = {}

    for column in df.columns:
        base_name = _column_name_from_value(column)
        count = seen.get(base_name, 0)
        seen[base_name] = count + 1
        rename_map[column] = base_name if count == 0 else f"{base_name}_{count + 1}"

    return df.rename(columns=rename_map).copy()


def _build_execution_context(
    connection: duckdb.DuckDBPyConnection,
    excel_dir: str | Path | None = None,
) -> tuple[SqlRewriteContext, dict[str, WorkbookRecord], dict[Path, dict[str, pd.DataFrame]]]:
    """Load workbooks with pandas, register DuckDB tables, and build rewrite mappings."""
    rewrite_context: SqlRewriteContext = {
        "table_identifiers": set(),
        "column_identifiers": set(),
        "table_name_map": {},
        "table_alias_map": {},
        "column_name_map": {},
        "column_alias_map": {},
    }
    workbook_records: dict[str, WorkbookRecord] = {}
    workbook_sheet_data: dict[Path, dict[str, pd.DataFrame]] = {}
    table_name_counts: dict[str, int] = {}

    selected_excel_dir = excel_dir if excel_dir is not None else EXCEL_DIR
    for file_path in list_excel_files(selected_excel_dir):
        original_table_name = file_path.stem
        workbook_table_name = _table_name_from_file(file_path.name)

        # Use simple pandas read_excel API to load all sheets in one call.
        sheet_data = pd.read_excel(file_path, sheet_name=None)
        if not sheet_data:
            continue

        normalized_sheet_data: dict[str, pd.DataFrame] = {}
        workbook_sheet_data[file_path] = normalized_sheet_data

        first_table_name: str | None = None

        for sheet_name, raw_df in sheet_data.items():
            normalized_df = _normalize_columns(raw_df)
            normalized_sheet_data[sheet_name] = normalized_df

            base_table_name = _table_name_from_sheet(file_path.name, str(sheet_name))
            table_count = table_name_counts.get(base_table_name, 0)
            table_name_counts[base_table_name] = table_count + 1
            table_name = base_table_name if table_count == 0 else f"{base_table_name}_{table_count + 1}"

            if first_table_name is None:
                first_table_name = table_name

            combined_original_name = f"{original_table_name}__{sheet_name}"

            rewrite_context["table_name_map"][combined_original_name] = table_name
            rewrite_context["table_alias_map"][_canonical_identifier(combined_original_name)] = table_name
            rewrite_context["table_alias_map"][_canonical_identifier(table_name)] = table_name
            rewrite_context["table_identifiers"].add(combined_original_name)
            rewrite_context["table_identifiers"].add(table_name)

            for raw_col, normalized_col in zip(raw_df.columns, normalized_df.columns, strict=False):
                raw_name = str(raw_col)
                normalized_name = str(normalized_col)
                rewrite_context["column_identifiers"].add(raw_name)
                rewrite_context["column_name_map"][raw_name] = normalized_name
                rewrite_context["column_alias_map"][_canonical_identifier(raw_name)] = normalized_name
                rewrite_context["column_alias_map"][_canonical_identifier(normalized_name)] = normalized_name

            source_name = f"_source_{table_name}"
            connection.register(source_name, normalized_df)
            connection.execute(f"DROP TABLE IF EXISTS {table_name}")
            connection.execute(f"CREATE TABLE {table_name} AS SELECT * FROM {source_name}")

            if combined_original_name != table_name:
                connection.execute(f'DROP VIEW IF EXISTS "{combined_original_name}"')
                connection.execute(f'CREATE VIEW "{combined_original_name}" AS SELECT * FROM {table_name}')

            record: WorkbookRecord = {
                "file_path": file_path,
                "table_name": table_name,
                "sheet_name": sheet_name,
            }
            workbook_records[combined_original_name] = record
            workbook_records[table_name] = record

        if first_table_name is not None:
            rewrite_context["table_name_map"][original_table_name] = first_table_name
            rewrite_context["table_alias_map"][_canonical_identifier(original_table_name)] = first_table_name
            rewrite_context["table_alias_map"][_canonical_identifier(workbook_table_name)] = first_table_name
            rewrite_context["table_identifiers"].add(original_table_name)
            rewrite_context["table_identifiers"].add(workbook_table_name)

            first_record = workbook_records.get(first_table_name)
            if first_record is not None:
                workbook_records[original_table_name] = first_record
                workbook_records[workbook_table_name] = first_record

    return rewrite_context, workbook_records, workbook_sheet_data