Source code for advanced_alchemy.operations

"""Advanced database operations for SQLAlchemy.

This module provides high-performance database operations that extend beyond basic CRUD
functionality. It implements specialized database operations optimized for bulk data
handling and schema management.

The operations module is designed to work seamlessly with SQLAlchemy Core and ORM,
providing efficient implementations for common database operations patterns.

Features
--------
- Cross-database ON CONFLICT/ON DUPLICATE KEY UPDATE operations
- MERGE statement support for Oracle and PostgreSQL 15+

Security
--------
This module constructs SQL statements using database identifiers (table and column names)
that MUST come from trusted sources only. All identifiers should originate from:

- SQLAlchemy model metadata (e.g., Model.__table__)
- Hardcoded strings in application code
- Validated configuration files

Never pass user input directly as table names, column names, or other SQL identifiers.
Data values are properly parameterized using bindparam() to prevent SQL injection.

Notes:
------
This module is designed to be database-agnostic where possible, with specialized
optimizations for specific database backends where appropriate.

See Also:
---------
- :mod:`sqlalchemy.sql.expression` : SQLAlchemy Core expression language
- :mod:`sqlalchemy.orm` : SQLAlchemy ORM functionality
- :mod:`advanced_alchemy.extensions` : Additional database extensions
"""

import functools
import re
from typing import TYPE_CHECKING, Any, Literal, NamedTuple, Optional, Union, cast
from uuid import UUID

from sqlalchemy import Boolean, Insert, Table, UniqueConstraint, bindparam, insert, select, text
from sqlalchemy.engine import Dialect
from sqlalchemy.ext.compiler import compiles
from sqlalchemy.sql import ClauseElement
from sqlalchemy.sql.elements import ColumnElement
from sqlalchemy.sql.expression import Executable

if TYPE_CHECKING:  # pragma: no cover - typing only
    from collections.abc import Iterable, Sequence

    from sqlalchemy.sql.compiler import SQLCompiler

UpsertKind = Literal["on_conflict", "merge", "insert_or_update", "fallback"]

__all__ = (
    "MergeStatement",
    "OnConflictUpsert",
    "SpannerUpsert",
    "UpsertKind",
    "UpsertStrategy",
    "compile_spanner_upsert_default",
    "resolve_upsert_strategy",
    "validate_identifier",
)

# Pattern for valid SQL identifiers (conservative - alphanumeric and underscore only)
_IDENTIFIER_PATTERN = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*$")


[docs] def validate_identifier(name: str, identifier_type: str = "identifier") -> str: """Validate a SQL identifier to ensure it's safe for use in SQL statements. This function provides validation for SQL identifiers (table names, column names, etc.) to ensure they contain only safe characters. While the operations in this module should only receive identifiers from trusted sources, this validation adds an extra layer of security. Note: SQL keywords (like 'select', 'insert', etc.) are allowed as they can be properly quoted/escaped by SQLAlchemy when used as identifiers. Args: name: The identifier to validate identifier_type: Type of identifier for error messages (e.g., "column", "table") Returns: The validated identifier Raises: ValueError: If the identifier is empty or contains invalid characters Examples: >>> validate_identifier("user_id") 'user_id' >>> validate_identifier("users_table", "table") 'users_table' >>> validate_identifier("select") # SQL keywords are allowed 'select' >>> validate_identifier( ... "drop table users; --" ... ) # Raises ValueError - contains invalid characters """ if not name: msg = f"Empty {identifier_type} name provided" raise ValueError(msg) if not _IDENTIFIER_PATTERN.match(name): msg = f"Invalid {identifier_type} name: '{name}'. Only alphanumeric characters and underscores are allowed." raise ValueError(msg) return name
[docs] class MergeStatement(Executable, ClauseElement): """A MERGE statement for Oracle and PostgreSQL 15+. This provides a high-level interface for MERGE operations that can handle both matched and unmatched conditions. """ inherit_cache = True
[docs] def __init__( self, table: Table, source: Union[ClauseElement, str], on_condition: ClauseElement, when_matched_update: Optional[dict[str, Any]] = None, when_not_matched_insert: Optional[dict[str, Any]] = None, ) -> None: """Initialize a MERGE statement. Args: table: Target table for the merge operation source: Source data (can be a subquery or table) on_condition: Condition for matching rows when_matched_update: Values to update when rows match when_not_matched_insert: Values to insert when rows don't match """ self.table = table self.source = source self.on_condition = on_condition self.when_matched_update = when_matched_update or {} self.when_not_matched_insert = when_not_matched_insert or {}
class _MergeSourceColumn(ColumnElement[Any]): """Dialect-quoted reference to a column on the MERGE ``src`` alias.""" inherit_cache = True def __init__(self, column_name: str, column_type: Any) -> None: self.column_name = column_name self.type = column_type @compiles(_MergeSourceColumn) def _compile_merge_source_column( # pyright: ignore[reportUnusedFunction] element: _MergeSourceColumn, compiler: "SQLCompiler", **kwargs: Any ) -> str: _ = kwargs return f"src.{compiler.preparer.quote(element.column_name)}" class _MergeMatchCondition(ColumnElement[bool]): """Dialect-quoted equality predicates for MERGE target/source keys.""" inherit_cache = True type = Boolean() def __init__(self, match_columns: "Sequence[str]") -> None: self.match_columns = tuple(match_columns) @compiles(_MergeMatchCondition) def _compile_merge_match_condition( # pyright: ignore[reportUnusedFunction] element: _MergeMatchCondition, compiler: "SQLCompiler", **kwargs: Any ) -> str: _ = kwargs quote = compiler.preparer.quote return " AND ".join(f"tgt.{quote(column)} = src.{quote(column)}" for column in element.match_columns) def _merge_source_column(table: Table, column_name: str) -> _MergeSourceColumn: return _MergeSourceColumn(column_name, table.c[column_name].type) # PostgreSQL version constant POSTGRES_MERGE_VERSION = 15 @compiles(MergeStatement) def compile_merge_default(element: MergeStatement, compiler: "SQLCompiler", **kwargs: Any) -> str: """Default compilation - raises error for unsupported dialects.""" _ = element, kwargs # Unused parameters dialect_name = compiler.dialect.name msg = f"MERGE statement not supported for dialect '{dialect_name}'" raise NotImplementedError(msg) @compiles(MergeStatement, "oracle") def compile_merge_oracle(element: MergeStatement, compiler: "SQLCompiler", **kwargs: Any) -> str: """Compile MERGE statement for Oracle.""" quote = compiler.preparer.quote table_name = compiler.preparer.format_table(element.table) if isinstance(element.source, str): source_str = element.source if source_str.upper().startswith("SELECT") and "FROM DUAL" not in source_str.upper(): source_str = f"{source_str} FROM DUAL" source_clause = f"({source_str})" else: compiled_source = compiler.process(element.source, **kwargs) source_clause = f"({compiled_source})" merge_sql = f"MERGE INTO {table_name} tgt USING {source_clause} src ON (" merge_sql += compiler.process(element.on_condition, **kwargs) merge_sql += ")" if element.when_matched_update: merge_sql += " WHEN MATCHED THEN UPDATE SET " updates = [] for column, value in element.when_matched_update.items(): if hasattr(value, "_compiler_dispatch"): compiled_value = compiler.process(value, **kwargs) else: compiled_value = compiler.process(value, **kwargs) updates.append(f"tgt.{quote(column)} = {compiled_value}") # pyright: ignore merge_sql += ", ".join(updates) # pyright: ignore if element.when_not_matched_insert: columns = list(element.when_not_matched_insert.keys()) values = list(element.when_not_matched_insert.values()) merge_sql += " WHEN NOT MATCHED THEN INSERT (" merge_sql += ", ".join(quote(column) for column in columns) merge_sql += ") VALUES (" compiled_values = [] for value in values: if hasattr(value, "_compiler_dispatch"): compiled_value = compiler.process(value, **kwargs) else: compiled_value = compiler.process(value, **kwargs) compiled_values.append(compiled_value) # pyright: ignore merge_sql += ", ".join(compiled_values) # pyright: ignore merge_sql += ")" return merge_sql @compiles(MergeStatement, "postgresql") def compile_merge_postgresql(element: MergeStatement, compiler: "SQLCompiler", **kwargs: Any) -> str: """Compile MERGE statement for PostgreSQL 15+.""" dialect = compiler.dialect if ( hasattr(dialect, "server_version_info") and dialect.server_version_info and dialect.server_version_info[0] < POSTGRES_MERGE_VERSION ): msg = "MERGE statement requires PostgreSQL 15 or higher" raise NotImplementedError(msg) quote = compiler.preparer.quote table_name = compiler.preparer.format_table(element.table) if isinstance(element.source, str): # Wrap raw string source and alias as src source_clause = f"({element.source}) AS src" else: # Ensure the compiled source is parenthesized and has a stable alias 'src' compiled_source = compiler.process(element.source, **kwargs) compiled_trim = compiled_source.strip() if compiled_trim.startswith("("): # Already parenthesized; check for alias after closing paren has_outer_alias = ( re.search(r"\)\s+(AS\s+)?[a-zA-Z_][a-zA-Z0-9_]*\s*$", compiled_trim, re.IGNORECASE) is not None ) source_clause = compiled_trim if has_outer_alias else f"{compiled_trim} AS src" else: # Not parenthesized: wrap and alias source_clause = f"({compiled_trim}) AS src" merge_sql = f"MERGE INTO {table_name} AS tgt USING {source_clause} ON (" merge_sql += compiler.process(element.on_condition, **kwargs) merge_sql += ")" if element.when_matched_update: merge_sql += " WHEN MATCHED THEN UPDATE SET " updates = [] for column, value in element.when_matched_update.items(): if hasattr(value, "_compiler_dispatch"): compiled_value = compiler.process(value, **kwargs) else: compiled_value = compiler.process(value, **kwargs) updates.append(f"{quote(column)} = {compiled_value}") # pyright: ignore merge_sql += ", ".join(updates) # pyright: ignore if element.when_not_matched_insert: columns = list(element.when_not_matched_insert.keys()) values = list(element.when_not_matched_insert.values()) merge_sql += " WHEN NOT MATCHED THEN INSERT (" merge_sql += ", ".join(quote(column) for column in columns) merge_sql += ") VALUES (" compiled_values = [] for value in values: if hasattr(value, "_compiler_dispatch"): compiled_value = compiler.process(value, **kwargs) else: compiled_value = compiler.process(value, **kwargs) compiled_values.append(compiled_value) # pyright: ignore merge_sql += ", ".join(compiled_values) # pyright: ignore merge_sql += ")" return merge_sql @compiles(MergeStatement, "mssql") def compile_merge_mssql(element: MergeStatement, compiler: "SQLCompiler", **kwargs: Any) -> str: """Compile MERGE for SQL Server. Emits T-SQL form: MERGE INTO {table} AS tgt USING (VALUES (...), (...)) AS src(col1, col2) ON tgt.col = src.col [AND ...] WHEN MATCHED THEN UPDATE SET tgt.col = src.col [, ...] WHEN NOT MATCHED THEN INSERT (col1, ...) VALUES (src.col1, ...) ; The trailing semicolon is REQUIRED for T-SQL MERGE — without it SQL Server raises a syntax error. The repository hydrates with one exact-key re-SELECT; emitting ``OUTPUT inserted.*`` here would transfer the same rows twice. All identifiers are quoted via ``compiler.preparer`` so reserved words like ``key`` survive. """ quote = compiler.preparer.quote table_name = compiler.preparer.format_table(element.table) if isinstance(element.source, str): source_clause = f"({element.source})" else: compiled_source = compiler.process(element.source, **kwargs) compiled_trim = compiled_source.strip() source_clause = compiled_trim if compiled_trim.startswith("(") else f"({compiled_trim})" merge_sql = f"MERGE INTO {table_name} AS tgt USING {source_clause} ON (" merge_sql += compiler.process(element.on_condition, **kwargs) merge_sql += ")" if element.when_matched_update: merge_sql += " WHEN MATCHED THEN UPDATE SET " updates: list[str] = [] for column, value in element.when_matched_update.items(): compiled_value = compiler.process(value, **kwargs) updates.append(f"tgt.{quote(column)} = {compiled_value}") merge_sql += ", ".join(updates) if element.when_not_matched_insert: columns = list(element.when_not_matched_insert.keys()) values = list(element.when_not_matched_insert.values()) merge_sql += " WHEN NOT MATCHED THEN INSERT (" merge_sql += ", ".join(quote(col) for col in columns) merge_sql += ") VALUES (" compiled_values: list[str] = [compiler.process(value, **kwargs) for value in values] merge_sql += ", ".join(compiled_values) merge_sql += ")" merge_sql += ";" return merge_sql
[docs] class SpannerUpsert(Executable, ClauseElement): """Spanner-specific bulk upsert primitive (``INSERT OR UPDATE INTO``). Cloud Spanner does not implement SQL ``MERGE``; the closest DML form is ``INSERT OR UPDATE INTO {table} ({cols}) VALUES (...), (...)``. We model this with its own ClauseElement (instead of overloading ``MergeStatement``) because the syntax has no ``USING`` / ``WHEN MATCHED`` shape. The PK must be present in every row — Spanner does not auto-generate PKs via DML. This low-level construct does not expose SQLAlchemy result-column metadata; repository code that needs hydration uses a regular :class:`~sqlalchemy.sql.dml.Insert` with ``OR UPDATE`` and ``returning()`` so the Spanner dialect emits ``THEN RETURN``. """ inherit_cache = True
[docs] def __init__(self, table: Table, values_list: list[dict[str, Any]]) -> None: """Initialize a Spanner INSERT_OR_UPDATE. Args: table: Target table for the upsert. values_list: Rows to insert/update. All rows MUST share the same keys; PK columns MUST be present in every row. Raises: ValueError: ``values_list`` is empty or rows have heterogeneous keys. """ if not values_list: msg = "values_list must not be empty" raise ValueError(msg) first_keys = tuple(values_list[0].keys()) first_keyset = set(first_keys) for idx, row in enumerate(values_list[1:], start=1): if set(row.keys()) != first_keyset: msg = f"All entries in values_list must share the same keys (row {idx} differs from row 0)" raise ValueError(msg) augmented = _augment_with_pk_defaults(table, values_list) self.table = table self.values_list = augmented self.columns: tuple[str, ...] = tuple(augmented[0].keys())
[docs] @compiles(SpannerUpsert) def compile_spanner_upsert_default(element: SpannerUpsert, compiler: "SQLCompiler", **kwargs: Any) -> str: """Default compilation - raises error for non-spanner dialects.""" _ = element, kwargs dialect_name = compiler.dialect.name msg = f"SpannerUpsert is only compilable for a Spanner dialect, not '{dialect_name}'" raise NotImplementedError(msg)
@compiles(SpannerUpsert, "spanner") @compiles(SpannerUpsert, "spanner+spanner") def compile_spanner_upsert(element: SpannerUpsert, compiler: "SQLCompiler", **kwargs: Any) -> str: """Compile Spanner INSERT_OR_UPDATE INTO ... VALUES (...), (...).""" table_name = compiler.preparer.format_table(element.table) cols = ", ".join(compiler.preparer.quote(column) for column in element.columns) row_strs: list[str] = [] for idx, row in enumerate(element.values_list): placeholders: list[str] = [] for col in element.columns: column = element.table.c[col] bp = bindparam(f"row{idx}_{col}", value=row[col], type_=column.type) placeholders.append(compiler.process(bp, **kwargs)) row_strs.append(f"({', '.join(placeholders)})") return f"INSERT OR UPDATE INTO {table_name} ({cols}) VALUES {', '.join(row_strs)}" # noqa: S608
[docs] class OnConflictUpsert: """Cross-database upsert operation using dialect-specific constructs. This class provides a unified interface for upsert operations across different database backends using their native ON CONFLICT or ON DUPLICATE KEY UPDATE mechanisms. """
[docs] @staticmethod def supports_native_upsert(dialect_name: str) -> bool: """Check if the dialect supports the single-row ``create_upsert`` API. This flag is scoped to the per-row ``INSERT ... ON CONFLICT`` / ``ON DUPLICATE KEY UPDATE`` dialects that :meth:`OnConflictUpsert.create_upsert` can compile directly. The bulk ``MERGE`` (mssql, oracle) and ``INSERT OR UPDATE`` (spanner) primitives are dispatched separately by :meth:`Repository.upsert_many` via :func:`resolve_upsert_strategy` and are intentionally **not** reported here. Args: dialect_name: Name of the database dialect Returns: ``True`` for postgresql / cockroachdb / sqlite / mysql / mariadb / duckdb; ``False`` otherwise. """ return dialect_name in {"postgresql", "cockroachdb", "sqlite", "mysql", "mariadb", "duckdb"}
[docs] @staticmethod def create_upsert( table: Table, values: dict[str, Any], conflict_columns: list[str], update_columns: Optional[list[str]] = None, dialect_name: Optional[str] = None, validate_identifiers: bool = False, ) -> Insert: """Create a dialect-specific upsert statement. Args: table: Target table for the upsert values: Values to insert/update conflict_columns: Columns that define the conflict condition update_columns: Columns to update on conflict (defaults to all non-conflict columns) dialect_name: Database dialect name (auto-detected if not provided) validate_identifiers: If True, validate column names for safety (default: False) Returns: A SQLAlchemy ``Insert`` for the ON-CONFLICT dialects (postgresql / cockroachdb / sqlite / duckdb / mysql / mariadb). MSSQL, Oracle, and Spanner are handled by the bulk ``MERGE`` / ``INSERT OR UPDATE`` path in :func:`OnConflictUpsert.create_merge_many` and :func:`OnConflictUpsert.create_insert_or_update_many`, accessed through :func:`resolve_upsert_strategy` from the repository layer — they are not exposed via this single-row API. Raises: NotImplementedError: If the dialect doesn't support native upsert ValueError: If validate_identifiers is True and invalid identifiers are found """ if validate_identifiers: for col in conflict_columns: validate_identifier(col, "conflict column") if update_columns: for col in update_columns: validate_identifier(col, "update column") for col in values: validate_identifier(col, "column") update_columns = _resolve_update_columns(table, values, conflict_columns, update_columns) if dialect_name in {"postgresql", "sqlite", "duckdb", "cockroachdb"}: from sqlalchemy.dialects.postgresql import insert as pg_insert pg_insert_stmt = pg_insert(table).values(values) if not update_columns: return pg_insert_stmt.on_conflict_do_nothing(index_elements=conflict_columns) return pg_insert_stmt.on_conflict_do_update( index_elements=conflict_columns, set_={col: pg_insert_stmt.excluded[col] for col in update_columns}, ) if dialect_name in {"mysql", "mariadb"}: from sqlalchemy.dialects.mysql import insert as mysql_insert mysql_insert_stmt = mysql_insert(table).values(values) mysql_updates = ( {col: mysql_insert_stmt.inserted[col] for col in update_columns} if update_columns else {conflict_columns[0]: mysql_insert_stmt.inserted[conflict_columns[0]]} ) return mysql_insert_stmt.on_duplicate_key_update(**mysql_updates) msg = f"Native upsert not supported for dialect '{dialect_name}'" raise NotImplementedError(msg)
[docs] @staticmethod def create_insert_or_update_many(table: Table, values_list: list[dict[str, Any]]) -> Insert: """Create a Spanner ``INSERT OR UPDATE`` using SQLAlchemy's Insert. A regular :class:`~sqlalchemy.sql.dml.Insert` retains result-column metadata, allowing callers to add ``returning(model_type)``. The Spanner dialect compiles that combination to ``THEN RETURN``. Args: table: Target table for the upsert. values_list: Homogeneous rows to insert or update. Returns: A multi-values insert prefixed with Spanner's ``OR UPDATE`` token. """ return insert(table).prefix_with("OR UPDATE").values(values_list)
[docs] @staticmethod def create_merge_upsert( # noqa: C901, PLR0915 table: Table, values: dict[str, Any], conflict_columns: list[str], update_columns: Optional[list[str]] = None, dialect_name: Optional[str] = None, validate_identifiers: bool = False, ) -> tuple[MergeStatement, dict[str, Any]]: """Create a MERGE-based upsert for Oracle/PostgreSQL 15+. For Oracle databases, this method automatically generates values for primary key columns that have callable defaults (such as UUID generation functions). This is necessary because Oracle MERGE statements cannot use Python callable defaults directly in the INSERT clause. Args: table: Target table for the upsert values: Values to insert/update conflict_columns: Columns that define the matching condition update_columns: Columns to update on match (defaults to all non-conflict columns) dialect_name: Database dialect name (used to determine Oracle-specific syntax) validate_identifiers: If True, validate column names for safety (default: False) Returns: A tuple of (MergeStatement, additional_params) where additional_params contains any generated values (like Oracle UUID primary keys) Raises: ValueError: If validate_identifiers is True and invalid identifiers are found """ if validate_identifiers: for col in conflict_columns: validate_identifier(col, "conflict column") if update_columns: for col in update_columns: validate_identifier(col, "update column") for col in values: validate_identifier(col, "column") update_columns = _resolve_update_columns(table, values, conflict_columns, update_columns) additional_params: dict[str, Any] = {} source: Union[ClauseElement, str] insert_columns: list[str] when_not_matched_insert: dict[str, Any] if dialect_name == "oracle": labeled_columns: list[ColumnElement[Any]] = [] for key, value in values.items(): column = table.c[key] labeled_columns.append(bindparam(key, value=value, type_=column.type).label(key)) pk_col_with_seq = None for pk_column in table.primary_key.columns: if pk_column.name in values or pk_column.default is None: continue arg = getattr(pk_column.default, "arg", None) if callable(arg): default_value = invoke_python_default(arg) if default_value is DEFAULT_INVOKE_FAILED: continue if isinstance(default_value, UUID): default_value = default_value.hex additional_params[pk_column.name] = default_value labeled_columns.append( bindparam(pk_column.name, value=default_value, type_=pk_column.type).label(pk_column.name) ) elif hasattr(pk_column.default, "next_value"): pk_col_with_seq = pk_column # Oracle requires FROM DUAL for SELECT statements without tables source_query = select(*labeled_columns) # Add FROM DUAL for Oracle source_query = source_query.select_from(text("DUAL")) source = source_query.subquery("src") insert_columns = [label_col.name for label_col in labeled_columns] when_not_matched_insert = {col_name: _merge_source_column(table, col_name) for col_name in insert_columns} if pk_col_with_seq is not None: insert_columns.append(pk_col_with_seq.name) when_not_matched_insert[pk_col_with_seq.name] = cast("Any", pk_col_with_seq.default).next_value() elif dialect_name in {"postgresql", "cockroachdb"}: labeled_columns = [] for key, value in values.items(): column = table.c[key] bp = bindparam(f"src_{key}", value=value, type_=column.type) labeled_columns.append(bp.label(key)) source = select(*labeled_columns).subquery("src") insert_columns = list(values.keys()) when_not_matched_insert = {col: _merge_source_column(table, col) for col in insert_columns} else: placeholders = ", ".join([f"%({key})s" for key in values]) col_names = ", ".join(values.keys()) source = f"(SELECT * FROM (VALUES ({placeholders})) AS src({col_names}))" # noqa: S608 insert_columns = list(values.keys()) when_not_matched_insert = {col: bindparam(col) for col in insert_columns} on_condition = _MergeMatchCondition(conflict_columns) if dialect_name in {"postgresql", "cockroachdb", "oracle"}: when_matched_update: dict[str, Any] = { col: _merge_source_column(table, col) for col in update_columns if col in values } else: when_matched_update = {col: bindparam(col) for col in update_columns if col in values} # For Oracle, we need to ensure the keys in when_not_matched_insert match the insert_columns if dialect_name == "oracle": final_insert_mapping = {} for col_name in insert_columns: if col_name in when_not_matched_insert: final_insert_mapping[col_name] = when_not_matched_insert[col_name] when_not_matched_insert = final_insert_mapping merge_stmt = MergeStatement( table=table, source=source, on_condition=on_condition, when_matched_update=when_matched_update, when_not_matched_insert=when_not_matched_insert, ) return merge_stmt, additional_params # pyright: ignore[reportUnknownVariableType]
[docs] @staticmethod def create_upsert_many( table: Table, values_list: list[dict[str, Any]], conflict_columns: list[str], update_columns: Optional[list[str]] = None, dialect_name: Optional[str] = None, validate_identifiers: bool = False, model_type: Optional[type[Any]] = None, ) -> tuple[Insert, bool]: """Build a dialect-specific bulk Insert with ON CONFLICT / ON DUPLICATE KEY UPDATE. Compiles to a single ``INSERT ... VALUES (...), (...), ...`` per chunk so the round-trip cost is fixed regardless of batch size. Args: table: Target table for the upsert. values_list: Rows to insert/update. All rows MUST share the same keys. conflict_columns: Columns that define the conflict / match condition. update_columns: Columns to update on conflict (defaults to all non-conflict keys from the first row). dialect_name: Database dialect name; determines compile path. validate_identifiers: If True, validate column identifiers for safety. model_type: Optional ORM model target used for ORM-aware RETURNING. Returns: A tuple ``(statement, supports_returning)`` where ``supports_returning`` is True for postgresql / cockroachdb / sqlite / duckdb and False for mysql / mariadb. Raises: ValueError: ``values_list`` is empty, rows have heterogeneous keys, or identifier validation fails. NotImplementedError: The dialect does not support an ON CONFLICT style native bulk upsert. """ _validate_bulk_inputs(values_list, conflict_columns, update_columns, validate_identifiers) resolved_update_columns = _resolve_update_columns( table, values_list[0], conflict_columns, update_columns, ) insert_target: Union[Table, type[Any]] = model_type if model_type is not None else table if dialect_name in {"postgresql", "sqlite", "duckdb", "cockroachdb"}: from sqlalchemy.dialects.postgresql import insert as pg_insert pg_stmt = pg_insert(insert_target).values(values_list) if not resolved_update_columns: return (pg_stmt.on_conflict_do_nothing(index_elements=conflict_columns), True) return ( pg_stmt.on_conflict_do_update( index_elements=conflict_columns, set_={col: pg_stmt.excluded[col] for col in resolved_update_columns}, ), True, ) if dialect_name in {"mysql", "mariadb"}: from sqlalchemy.dialects.mysql import insert as mysql_insert mysql_stmt = mysql_insert(insert_target).values(values_list) mysql_updates = ( {col: mysql_stmt.inserted[col] for col in resolved_update_columns} if resolved_update_columns else {conflict_columns[0]: mysql_stmt.inserted[conflict_columns[0]]} ) return ( mysql_stmt.on_duplicate_key_update(**mysql_updates), False, ) msg = f"Native bulk upsert not supported for dialect '{dialect_name}'" raise NotImplementedError(msg)
[docs] @staticmethod def create_merge_many( table: Table, values_list: list[dict[str, Any]], conflict_columns: list[str], update_columns: Optional[list[str]] = None, dialect_name: Optional[str] = None, validate_identifiers: bool = False, ) -> tuple[Union[MergeStatement, list[MergeStatement]], dict[str, Any]]: """Build a bulk MERGE / executemany-fallback per dialect. Returns a single ``MergeStatement`` for dialects whose MERGE syntax supports a multi-row source (oracle, mssql, postgresql/cockroachdb), and a list of single-row ``MergeStatement`` (one per input row) for everything else. Args: table: Target table for the upsert. values_list: Rows to insert/update. All rows MUST share the same keys. conflict_columns: Columns that define the matching condition. update_columns: Columns to update on match (defaults to all non-conflict keys from the first row). dialect_name: Database dialect name; selects the source construction. validate_identifiers: If True, validate column identifiers for safety. Returns: A tuple ``(statement_or_list, additional_params)``. ``additional_params`` carries generated values (Oracle UUID PKs, MSSQL bound row values) that must be passed when executing. Raises: ValueError: ``values_list`` is empty, rows have heterogeneous keys, or identifier validation fails. """ _validate_bulk_inputs(values_list, conflict_columns, update_columns, validate_identifiers) values_list = _augment_with_pk_defaults(table, values_list) resolved_update_columns = _resolve_update_columns( table, values_list[0], conflict_columns, update_columns, ) if dialect_name == "oracle": return _build_oracle_bulk_merge(table, values_list, conflict_columns, resolved_update_columns) if dialect_name in {"postgresql", "cockroachdb"}: return _build_pg_bulk_merge(table, values_list, conflict_columns, resolved_update_columns) if dialect_name == "mssql": return _build_mssql_bulk_merge(table, values_list, conflict_columns, resolved_update_columns) stmts: list[MergeStatement] = [] combined_params: dict[str, Any] = {} for row in values_list: stmt, row_params = OnConflictUpsert.create_merge_upsert( table=table, values=row, conflict_columns=conflict_columns, update_columns=update_columns, dialect_name=dialect_name, validate_identifiers=False, ) stmts.append(stmt) combined_params.update(row_params) return stmts, combined_params
DEFAULT_INVOKE_FAILED: Any = object() def _resolve_update_columns( table: Table, available_columns: "Iterable[str]", conflict_columns: "Sequence[str]", update_columns: Optional["Sequence[str]"], ) -> list[str]: """Return update columns without keys that identify the target row. Updating a primary key during an upsert is unsafe when a different unique key is used as the conflict target, and Oracle rejects updates to columns referenced by the MERGE ``ON`` clause. Apply the same rule to every dialect so the operation has portable semantics. """ protected_columns = set(conflict_columns) for column in table.primary_key.columns: protected_columns.add(column.key) protected_columns.add(column.name) candidate_columns = list(update_columns) if update_columns is not None else list(available_columns) return [column for column in candidate_columns if column not in protected_columns] def invoke_python_default(arg: Any) -> Any: """Invoke a SQLAlchemy ``ColumnDefault.arg`` callable, tolerating both signatures. SQLAlchemy accepts both context-taking defaults (``lambda ctx: …``) and zero-arg defaults (``lambda: …``, ``uuid7``, ``datetime.utcnow``). Returns ``DEFAULT_INVOKE_FAILED`` if neither call shape produced a value. """ try: return arg(None) except TypeError: pass except (AttributeError, ValueError): return DEFAULT_INVOKE_FAILED try: return arg() except (TypeError, AttributeError, ValueError): return DEFAULT_INVOKE_FAILED def _augment_with_pk_defaults( table: Table, values_list: list[dict[str, Any]], ) -> list[dict[str, Any]]: """Invoke Python-callable PK defaults for any rows missing those columns. Callers like the Litestar session/store backends invoke ``OnConflictUpsert.create_upsert`` (and thus ``create_merge_many``) with a values dict that omits the PK — they expect SQLAlchemy's ORM flush to populate the Python ``default=uuid7`` factory. The native dispatch path bypasses that flush, so we invoke the default here, once per row, before building the dialect-specific ``MERGE`` / ``INSERT OR UPDATE``. Columns without a Python default (autoincrement / IDENTITY / Sequence) are left untouched so the database supplies them. """ if not values_list: return values_list first_keys = set(values_list[0].keys()) pk_defaults: list[tuple[str, Any]] = [] for pk_col in table.primary_key.columns: if pk_col.name in first_keys: continue default = pk_col.default arg = getattr(default, "arg", None) if default is not None else None if callable(arg): pk_defaults.append((pk_col.name, arg)) if not pk_defaults: return values_list augmented: list[dict[str, Any]] = [] for row in values_list: new_row = dict(row) for col_name, arg in pk_defaults: value = invoke_python_default(arg) if value is not DEFAULT_INVOKE_FAILED: new_row[col_name] = value augmented.append(new_row) return augmented def _validate_bulk_inputs( values_list: list[dict[str, Any]], conflict_columns: list[str], update_columns: Optional[list[str]], validate_identifiers_flag: bool, ) -> None: """Shared input guard for create_upsert_many / create_merge_many. Raises ValueError on empty list, heterogeneous keys, or invalid identifiers when validation is requested. """ if not values_list: msg = "values_list must not be empty" raise ValueError(msg) first_keys = set(values_list[0].keys()) for idx, row in enumerate(values_list[1:], start=1): if set(row.keys()) != first_keys: msg = f"All entries in values_list must share the same keys (row {idx} differs from row 0)" raise ValueError(msg) if validate_identifiers_flag: for col in conflict_columns: validate_identifier(col, "conflict column") if update_columns: for col in update_columns: validate_identifier(col, "update column") for col in first_keys: validate_identifier(col, "column") def _collect_oracle_pk_defaults( table: Table, row: dict[str, Any], idx: int, additional_params: dict[str, Any], ) -> list["ColumnElement[Any]"]: """Generate PK default bindparams for one Oracle MERGE row. Mirrors the per-row PK-default block in create_merge_upsert but namespaces the bindparam names with ``row{idx}_pk_`` so each row in the bulk source has its own unique param name (Oracle MERGE has no implicit row identity). """ pk_columns: list[ColumnElement[Any]] = [] for pk_column in table.primary_key.columns: if pk_column.name in row or pk_column.default is None: continue arg = getattr(pk_column.default, "arg", None) if not callable(arg): continue default_value = invoke_python_default(arg) if default_value is DEFAULT_INVOKE_FAILED: continue if isinstance(default_value, UUID): default_value = default_value.hex param_name = f"row{idx}_pk_{pk_column.name}" additional_params[param_name] = default_value pk_columns.append(bindparam(param_name, value=default_value, type_=pk_column.type).label(pk_column.name)) return pk_columns def _build_oracle_bulk_merge( table: Table, values_list: list[dict[str, Any]], conflict_columns: list[str], update_columns: list[str], ) -> tuple[MergeStatement, dict[str, Any]]: """Construct an Oracle MERGE whose source is ``SELECT ... FROM DUAL UNION ALL ...``.""" first_keys = list(values_list[0].keys()) additional_params: dict[str, Any] = {} per_row_selects: list[Any] = [] pk_default_names: list[str] = [] for idx, row in enumerate(values_list): row_columns: list[ColumnElement[Any]] = [] for key in first_keys: column = table.c[key] bp = bindparam(f"row{idx}_{key}", value=row[key], type_=column.type) row_columns.append(bp.label(key)) pk_extras = _collect_oracle_pk_defaults(table, row, idx, additional_params) if idx == 0: pk_default_names = [col.name for col in pk_extras] row_columns.extend(pk_extras) per_row_selects.append(select(*row_columns).select_from(text("DUAL"))) unified = per_row_selects[0] if len(per_row_selects) == 1 else per_row_selects[0].union_all(*per_row_selects[1:]) source = unified.subquery("src") insert_columns = list(first_keys) + pk_default_names when_not_matched_insert: dict[str, Any] = {col: _merge_source_column(table, col) for col in insert_columns} when_matched_update: dict[str, Any] = { col: _merge_source_column(table, col) for col in update_columns if col in first_keys } on_condition = _MergeMatchCondition(conflict_columns) return ( MergeStatement( table=table, source=source, on_condition=on_condition, when_matched_update=when_matched_update, when_not_matched_insert=when_not_matched_insert, ), additional_params, ) def _build_pg_bulk_merge( table: Table, values_list: list[dict[str, Any]], conflict_columns: list[str], update_columns: list[str], ) -> tuple[MergeStatement, dict[str, Any]]: """Construct a PostgreSQL/CockroachDB MERGE whose source is ``SELECT ... UNION ALL ...``.""" first_keys = list(values_list[0].keys()) per_row_selects: list[Any] = [] for idx, row in enumerate(values_list): row_columns: list[ColumnElement[Any]] = [] for key in first_keys: column = table.c[key] bp = bindparam(f"src_row{idx}_{key}", value=row[key], type_=column.type) row_columns.append(bp.label(key)) per_row_selects.append(select(*row_columns)) unified = per_row_selects[0] if len(per_row_selects) == 1 else per_row_selects[0].union_all(*per_row_selects[1:]) source = unified.subquery("src") when_not_matched_insert: dict[str, Any] = {col: _merge_source_column(table, col) for col in first_keys} when_matched_update: dict[str, Any] = { col: _merge_source_column(table, col) for col in update_columns if col in first_keys } on_condition = _MergeMatchCondition(conflict_columns) return ( MergeStatement( table=table, source=source, on_condition=on_condition, when_matched_update=when_matched_update, when_not_matched_insert=when_not_matched_insert, ), {}, ) def _build_mssql_bulk_merge( table: Table, values_list: list[dict[str, Any]], conflict_columns: list[str], update_columns: list[str], ) -> tuple[MergeStatement, dict[str, Any]]: """Construct an MSSQL ``MergeStatement`` whose source is a ``text(...).bindparams(...)`` VALUES clause. Using ``text()`` with explicit :class:`~sqlalchemy.sql.expression.BindParameter` children lets the MSSQL compiler translate the ``:row0_*`` markers to ``?`` placeholders that pyodbc understands — a literal-string source would survive intact through compilation and fail at the driver with a ``[SQL Server]Incorrect syntax near ':'`` ProgrammingError. Column identifiers are bracket-quoted (``[name]``) so reserved T-SQL keywords like ``key`` survive in the alias column list, the ON clause, and the WHEN MATCHED / WHEN NOT MATCHED source references. """ first_keys = list(values_list[0].keys()) quoted_cols = [f"[{key}]" for key in first_keys] col_names = ", ".join(quoted_cols) bp_objects: list[Any] = [] row_fragments: list[str] = [] for idx, row in enumerate(values_list): placeholders: list[str] = [] for col_name in first_keys: bp_name = f"row{idx}_{col_name}" bp_objects.append(bindparam(bp_name, value=row[col_name], type_=table.c[col_name].type)) placeholders.append(f":{bp_name}") row_fragments.append(f"({', '.join(placeholders)})") source = text(f"(VALUES {', '.join(row_fragments)}) AS src({col_names})").bindparams(*bp_objects) when_not_matched_insert: dict[str, Any] = {col: _merge_source_column(table, col) for col in first_keys} when_matched_update: dict[str, Any] = { col: _merge_source_column(table, col) for col in update_columns if col in first_keys } on_condition = _MergeMatchCondition(conflict_columns) return ( MergeStatement( table=table, source=source, on_condition=on_condition, when_matched_update=when_matched_update, when_not_matched_insert=when_not_matched_insert, ), {}, )
[docs] class UpsertStrategy(NamedTuple): """Dispatch decision returned by :func:`resolve_upsert_strategy`. Tells the repository which native primitive to compile (``on_conflict`` / ``merge`` / ``insert_or_update``) or whether to take the existing SELECT-then-partition fallback. The ``conflict_columns`` field is the *validated* unique key (PK / UniqueConstraint / unique Index) and has exactly the same columns as the caller's ``match_fields``. """ kind: UpsertKind supports_returning: bool conflict_columns: tuple[str, ...] dialect_name: str
_DIALECTS_ON_CONFLICT_RETURNING: frozenset[str] = frozenset({"postgresql", "cockroachdb", "sqlite", "duckdb"}) _DIALECTS_ON_CONFLICT_NO_RETURNING: frozenset[str] = frozenset({"mysql", "mariadb"}) _DIALECTS_MERGE: frozenset[str] = frozenset({"oracle", "mssql"}) _DIALECTS_INSERT_OR_UPDATE: frozenset[str] = frozenset({"spanner", "spanner+spanner"}) def _native_primitive_for_dialect( dialect_name: str, insert_returning: Optional[bool] = None ) -> tuple[Optional[UpsertKind], bool]: """Return ``(kind, supports_returning)`` for the dialect's native upsert primitive. Returns ``(None, False)`` for dialects without a native primitive (fallback). """ if dialect_name in _DIALECTS_ON_CONFLICT_RETURNING: return ("on_conflict", True if insert_returning is None else insert_returning) if dialect_name in _DIALECTS_ON_CONFLICT_NO_RETURNING: return ("on_conflict", False) if dialect_name in _DIALECTS_MERGE: return ("merge", False) if dialect_name in _DIALECTS_INSERT_OR_UPDATE: return ("insert_or_update", True if insert_returning is None else insert_returning) return (None, False) def _get_native_unique_index_columns(index: Any) -> tuple[str, ...]: """Return simple unique-index columns, excluding filtered/expressional forms.""" if not index.unique: return () if any(index.dialect_options[dialect].get("where") is not None for dialect in ("postgresql", "sqlite", "mssql")): return () index_columns = tuple(index.columns) if len(index_columns) != len(index.expressions) or any( expression is not column for expression, column in zip(index.expressions, index_columns) ): return () return tuple(col.key for col in index_columns) def _mysql_unique_target_is_ambiguous(table: Table, primary_key_columns: tuple[str, ...]) -> bool: """Return whether MySQL could update through an unintended unique key.""" unique_keys: set[frozenset[str]] = set() if primary_key_columns: unique_keys.add(frozenset(primary_key_columns)) unique_keys.update( frozenset(col.key for col in constraint.columns) for constraint in table.constraints if isinstance(constraint, UniqueConstraint) ) unique_keys.update( frozenset(index_columns) for index in table.indexes if (index_columns := _get_native_unique_index_columns(index)) ) return len(unique_keys) > 1
[docs] def resolve_upsert_strategy( table: Table, match_fields: "Sequence[str]", dialect_name: Union[str, Dialect], ) -> UpsertStrategy: """Resolve the optimal upsert strategy for ``(table, match_fields, dialect)``. The result is cached for the process lifetime; ``Table`` objects are singletons per declarative class, so this is one decision per ``(model, match_fields, dialect)`` tuple. Resolution priority: 1. ``match_fields`` equals the table's primary key → native primitive for the dialect, ``conflict_columns`` is the PK. 2. A :class:`~sqlalchemy.UniqueConstraint` whose columns match exactly → native primitive where the backend can target it, ``conflict_columns`` is that constraint's columns. 3. A unique :class:`~sqlalchemy.Index` whose columns match exactly → native primitive where the backend can target it, ``conflict_columns`` is that index's columns. Spanner ``INSERT OR UPDATE`` only matches the primary key, and ambiguous MySQL/MariaDB unique targets fall back. 4. Otherwise → ``kind="fallback"``, ``supports_returning=False``. Args: table: Target table. Used by identity for caching. match_fields: Columns the caller wants to match on. Order-insensitive. dialect_name: Database dialect or dialect name. Passing the runtime dialect allows the resolver to honor its actual RETURNING capability. Returns: An :class:`UpsertStrategy` describing the decision. Raises: ValueError: ``match_fields`` is empty or contains a column not present on the table. """ if not match_fields: msg = "match_fields must not be empty" raise ValueError(msg) normalized = tuple(sorted(set(match_fields))) table_columns = set(table.c.keys()) missing = [column_name for column_name in normalized if column_name not in table_columns] if missing: msg = f"match_fields {missing!r} not present in table {table.name!r}" raise ValueError(msg) if isinstance(dialect_name, str): resolved_dialect_name = dialect_name insert_returning: Optional[bool] = None else: resolved_dialect_name = dialect_name.name insert_returning = bool(dialect_name.insert_returning) return _resolve_upsert_strategy_cached(table, normalized, resolved_dialect_name, insert_returning)
@functools.cache def _resolve_upsert_strategy_cached( table: Table, match_fields: tuple[str, ...], dialect_name: str, insert_returning: Optional[bool], ) -> UpsertStrategy: """Cached arm of :func:`resolve_upsert_strategy`. Keyed by identity of ``table``.""" kind, supports_returning = _native_primitive_for_dialect(dialect_name, insert_returning) match_set = set(match_fields) primary_key_columns = tuple(column.key for column in table.primary_key.columns) native_conflict_columns: Optional[tuple[str, ...]] = None if kind is not None and primary_key_columns and set(primary_key_columns) == match_set: native_conflict_columns = primary_key_columns elif kind is not None and kind != "insert_or_update": for constraint in table.constraints: if isinstance(constraint, UniqueConstraint): if constraint.deferrable: continue unique_constraint_columns = tuple(column.key for column in constraint.columns) if unique_constraint_columns and set(unique_constraint_columns) == match_set: native_conflict_columns = unique_constraint_columns break if native_conflict_columns is None: for index in table.indexes: unique_index_columns = _get_native_unique_index_columns(index) if unique_index_columns and set(unique_index_columns) == match_set: native_conflict_columns = unique_index_columns break mysql_target_is_ambiguous = dialect_name in {"mysql", "mariadb"} and _mysql_unique_target_is_ambiguous( table, primary_key_columns ) if kind is not None and native_conflict_columns is not None and not mysql_target_is_ambiguous: return UpsertStrategy( kind=kind, supports_returning=supports_returning, conflict_columns=native_conflict_columns, dialect_name=dialect_name, ) return UpsertStrategy( kind="fallback", supports_returning=False, conflict_columns=match_fields, dialect_name=dialect_name, )