# dbtk/dialects/base.py
from textwrap import dedent
from typing import List, Tuple, Any, Optional
from ..utils import wrap_at_comma
[docs]
class DatabaseDialect:
"""
Base database dialect. Default implementations match PostgreSQL/SQLite behavior.
Subclass and override to support a different database. Adding support for a new
engine means writing one class — no grep-and-branch across the ETL layer.
For unregistered databases (custom drivers without a matching dialect subclass),
dbtk falls back to this base class. The following features work without a dialect:
- All reads and writes (CSV, Excel, JSON, etc.)
- Plain ``SELECT``, ``INSERT``, ``UPDATE``, and ``DELETE`` via ``DataSurge``
- ``BulkSurge`` is database-specific and will not work
Features that require a dialect subclass:
- ``Table.upsert`` / ``DataSurge.upsert`` — base class generates
``INSERT … ON CONFLICT`` (PostgreSQL/SQLite syntax); will fail on other engines
- ``Table.merge`` / ``DataSurge.merge`` — base class generates a SQL Server-style
``MERGE``; will fail on engines that don't support that syntax
- ``column_defs_from_db`` — raises ``NotImplementedError``
"""
use_upsert = True # False for MERGE-based dialects (Oracle, SQL Server)
temp_table_all_cols = False # True when temp table must mirror all DB columns (Oracle)
temp_table_cleanup_commit = False # True when cleanup DDL needs an explicit commit (Oracle)
# ------------------------------------------------------------------
# Upsert (INSERT … ON CONFLICT / ON DUPLICATE KEY)
# ------------------------------------------------------------------
[docs]
def upsert_sql(self, table_name: str, cols_str: str, placeholders_str: str,
key_cols: List[str], update_cols: List[Tuple]) -> str:
"""INSERT … ON CONFLICT DO UPDATE SET — Postgres/SQLite default."""
conflict_cols = ', '.join(key_cols)
update_assignments = []
for col, ident, bind_name, db_expr in update_cols:
if db_expr and '#' in db_expr:
assignment = f"{ident} = {db_expr.replace('#', f'EXCLUDED.{ident}')}"
elif db_expr:
assignment = f"{ident} = {db_expr}"
else:
assignment = f"{ident} = EXCLUDED.{ident}"
update_assignments.append(assignment)
update_clause = ', '.join(update_assignments)
if len(update_assignments) > 4:
update_clause = wrap_at_comma(update_clause)
return dedent(f"""\
INSERT INTO {table_name} ({cols_str})
VALUES ({placeholders_str})
ON CONFLICT ({conflict_cols}) DO UPDATE SET {update_clause}""")
# ------------------------------------------------------------------
# Merge (MERGE … USING … ON … WHEN MATCHED / NOT MATCHED)
# ------------------------------------------------------------------
def _merge_source_clause(self, source_cols: str) -> str:
"""Return the USING subquery string. Override to add a FROM clause (e.g. Oracle's FROM dual)."""
return f"SELECT {source_cols}"
[docs]
def merge_sql(self, table_name: str, all_cols: List[Tuple],
key_conditions: List[str], update_cols: List[Tuple]) -> str:
"""Full MERGE statement. SQL Server-style by default (no FROM in USING subquery)."""
source_items = [f"{placeholder} AS {ident}" for _, ident, placeholder in all_cols]
source_cols = ', '.join(source_items)
if len(all_cols) > 4:
source_cols = wrap_at_comma(source_cols)
source_clause = self._merge_source_clause(source_cols)
update_assignments = [f"t.{ident} = s.{ident}" for _, ident in update_cols]
update_set = ', '.join(update_assignments)
if len(update_assignments) > 4:
update_set = wrap_at_comma(update_set)
insert_cols = ', '.join(ident for _, ident, _ in all_cols)
insert_values = ', '.join(f"s.{ident}" for _, ident, _ in all_cols)
if len(all_cols) > 4:
insert_cols = wrap_at_comma(insert_cols)
insert_values = wrap_at_comma(insert_values)
return dedent(f"""\
MERGE INTO {table_name} t
USING ({source_clause}) s
ON ({' AND '.join(key_conditions)})
WHEN MATCHED THEN
UPDATE SET {update_set}
WHEN NOT MATCHED THEN
INSERT ({insert_cols})
VALUES ({insert_values})""")
# ------------------------------------------------------------------
# SQL type mapping (used when generating temp table DDL)
# ------------------------------------------------------------------
[docs]
def sql_type(self, type_obj: Any, internal_size: Optional[int],
precision: Optional[int], scale: Optional[int]) -> str:
"""Map a driver type descriptor to a SQL type string. Generic fallback."""
return "VARCHAR(255)"
# ------------------------------------------------------------------
# Schema introspection
# ------------------------------------------------------------------
# ------------------------------------------------------------------
# Temp table hooks (MERGE-based dialects only: Oracle, SQL Server)
# ------------------------------------------------------------------
[docs]
def create_temp_table_ddl(self, table_name: str, col_info: list) -> Tuple[str, str]:
"""Return (temp_table_name, CREATE TABLE sql) for the given column info list."""
raise NotImplementedError(f"{type(self).__name__} does not use temp-table MERGE")
[docs]
def cleanup_temp_table_sql(self, temp_name: str) -> str:
"""Return SQL to clean up the temp table after a MERGE (TRUNCATE or DROP)."""
raise NotImplementedError(f"{type(self).__name__} does not use temp-table MERGE")