"""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 re
from typing import TYPE_CHECKING, Any, Literal, NamedTuple, Optional, Union, cast
from uuid import UUID
from weakref import WeakKeyDictionary
from sqlalchemy import Boolean, Insert, Select, 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",
"UpsertKind",
"UpsertStrategy",
"resolve_upsert_strategy",
"validate_identifier",
)
_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_key: str) -> _MergeSourceColumn:
column = table.c[column_key]
return _MergeSourceColumn(column.name, column.type)
def _column_names(table: Table, column_keys: "Iterable[str]") -> tuple[str, ...]:
"""Translate ``Column.key`` values to database column names for SQL rendering."""
return tuple(table.c[column_key].name for column_key in column_keys)
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
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: list[str] = [
f"tgt.{quote(column)} = {compiler.process(value, **kwargs)}"
for column, value in element.when_matched_update.items()
]
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(column) for column in columns)
merge_sql += ") VALUES ("
compiled_values: list[str] = [compiler.process(value, **kwargs) for value in values]
merge_sql += ", ".join(compiled_values)
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):
source_clause = f"({element.source}) AS src"
else:
compiled_source = compiler.process(element.source, **kwargs)
compiled_trim = compiled_source.strip()
if compiled_trim.startswith("("):
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:
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: list[str] = [
f"{quote(column)} = {compiler.process(value, **kwargs)}"
for column, value in element.when_matched_update.items()
]
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(column) for column in columns)
merge_sql += ") VALUES ("
compiled_values: list[str] = [compiler.process(value, **kwargs) for value in values]
merge_sql += ", ".join(compiled_values)
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. 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] = [
f"tgt.{quote(column)} = {compiler.process(value, **kwargs)}"
for column, value in element.when_matched_update.items()
]
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 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,
ON DUPLICATE KEY UPDATE, MERGE, or INSERT OR 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.
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)
index_elements = [table.c[col] for col in conflict_columns]
if not update_columns:
return pg_insert_stmt.on_conflict_do_nothing(index_elements=index_elements)
return pg_insert_stmt.on_conflict_do_update(
index_elements=index_elements,
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, rows: "Sequence[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.
rows: Homogeneous rows to insert or update.
Returns:
A multi-values insert prefixed with Spanner's ``OR UPDATE`` token.
Raises:
ValueError: ``rows`` is empty or rows have heterogeneous keys.
"""
_validate_bulk_inputs(rows, (), None, False)
prepared_rows = _apply_pk_defaults(table, rows)
return insert(table).prefix_with("OR UPDATE").values(prepared_rows)
[docs]
@staticmethod
def create_merge_upsert( # noqa: C901
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. Since Oracle requires ``FROM DUAL`` for ``SELECT``
statements without tables, the Oracle source subquery selects from ``DUAL``.
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]
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(column.name))
when_not_matched_insert = {table.c[key].name: _merge_source_column(table, key) for key in values}
for pk_column in table.primary_key.columns:
if pk_column.key in values or pk_column.default is None:
continue
arg = getattr(pk_column.default, "arg", None)
if callable(arg):
default_value = resolve_column_default(arg)
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)
)
when_not_matched_insert[pk_column.name] = _MergeSourceColumn(pk_column.name, pk_column.type)
elif hasattr(pk_column.default, "next_value"):
when_not_matched_insert[pk_column.name] = cast("Any", pk_column.default).next_value()
source = select(*labeled_columns).select_from(text("DUAL")).subquery("src")
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(column.name))
source = select(*labeled_columns).subquery("src")
when_not_matched_insert = {table.c[key].name: _merge_source_column(table, key) for key in values}
else:
placeholders = ", ".join([f"%({key})s" for key in values])
col_names = ", ".join(_column_names(table, values))
source = f"(SELECT * FROM (VALUES ({placeholders})) AS src({col_names}))" # noqa: S608
when_not_matched_insert = {table.c[key].name: bindparam(key) for key in values}
on_condition = _MergeMatchCondition(_column_names(table, conflict_columns))
if dialect_name in {"postgresql", "cockroachdb", "oracle"}:
when_matched_update: dict[str, Any] = {
table.c[col].name: _merge_source_column(table, col) for col in update_columns if col in values
}
else:
when_matched_update = {table.c[col].name: bindparam(col) for col in update_columns if col in values}
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,
rows: "Sequence[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,
) -> Insert:
"""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.
rows: 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 SQLAlchemy ``Insert`` statement configured for bulk upsert.
Raises:
ValueError: ``rows`` 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(rows, conflict_columns, update_columns, validate_identifiers)
resolved_update_columns = _resolve_update_columns(
table,
rows[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(list(rows))
index_elements = [table.c[col] for col in conflict_columns]
if not resolved_update_columns:
return pg_stmt.on_conflict_do_nothing(index_elements=index_elements)
return pg_stmt.on_conflict_do_update(
index_elements=index_elements,
set_={col: pg_stmt.excluded[col] for col in resolved_update_columns},
)
if dialect_name in {"mysql", "mariadb"}:
from sqlalchemy.dialects.mysql import insert as mysql_insert
mysql_stmt = mysql_insert(insert_target).values(list(rows))
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)
msg = f"Native bulk upsert not supported for dialect '{dialect_name}'"
raise NotImplementedError(msg)
[docs]
@staticmethod
def create_merge_many(
table: Table,
rows: "Sequence[dict[str, Any]]",
conflict_columns: list[str],
update_columns: Optional[list[str]] = None,
dialect_name: Optional[str] = None,
validate_identifiers: bool = False,
) -> MergeStatement:
"""Build a multi-row ``MergeStatement`` for dialects with native MERGE support.
Args:
table: Target table for the upsert.
rows: 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 single :class:`MergeStatement` binding all input rows.
Raises:
ValueError: ``rows`` is empty, rows have heterogeneous keys,
or identifier validation fails.
NotImplementedError: The dialect does not support bulk MERGE compilation.
"""
_validate_bulk_inputs(rows, conflict_columns, update_columns, validate_identifiers)
prepared_rows = _apply_pk_defaults(table, rows)
column_keys = list(prepared_rows[0].keys())
resolved_update_columns = _resolve_update_columns(
table,
column_keys,
conflict_columns,
update_columns,
)
if dialect_name == "oracle":
source = _build_union_merge_source(table, prepared_rows, column_keys, use_dual=True)
elif dialect_name in {"postgresql", "cockroachdb"}:
source = _build_union_merge_source(table, prepared_rows, column_keys, use_dual=False)
elif dialect_name == "mssql":
source = _build_values_merge_source(table, prepared_rows, column_keys)
else:
msg = f"Native bulk MERGE not supported for dialect '{dialect_name}'"
raise NotImplementedError(msg)
return _build_merge_statement(table, source, column_keys, conflict_columns, resolved_update_columns)
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 resolve_column_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``). The
context-taking shape is attempted first with ``None``; a signature
mismatch retries the zero-arg shape. Errors raised inside the default
itself — including a context-sensitive default that dereferences the
``None`` context — propagate so callers can route the operation through
the ORM flush, where a real execution context is available.
"""
try:
return arg(None)
except TypeError:
return arg()
def _apply_pk_defaults(
table: Table,
rows: "Sequence[dict[str, Any]]",
) -> list[dict[str, Any]]:
"""Invoke Python-callable PK defaults for any rows missing those columns.
Callers that construct values dicts without primary keys expect Python
``default=uuid7`` factories to populate before statement compilation when
bypassing the ORM flush. Columns without a Python callable default
(autoincrement / IDENTITY / Sequence) are left untouched so the database
supplies them.
"""
if not rows:
return list(rows)
first_keys = set(rows[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 list(rows)
augmented: list[dict[str, Any]] = []
for row in rows:
new_row = dict(row)
for col_name, arg in pk_defaults:
new_row[col_name] = resolve_column_default(arg)
augmented.append(new_row)
return augmented
def _validate_bulk_inputs(
rows: "Sequence[dict[str, Any]]",
conflict_columns: "Sequence[str]",
update_columns: Optional["Sequence[str]"],
validate_identifiers_flag: bool,
) -> None:
"""Validate non-empty homogeneous rows and optional SQL identifier safety."""
if not rows:
msg = "rows must not be empty"
raise ValueError(msg)
first_keys = set(rows[0].keys())
for idx, row in enumerate(rows[1:], start=1):
if set(row.keys()) != first_keys:
msg = f"All entries in rows 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 _build_union_merge_source(
table: Table,
rows: "Sequence[dict[str, Any]]",
column_keys: "Sequence[str]",
*,
use_dual: bool,
) -> ClauseElement:
"""Construct a ``SELECT ... [FROM DUAL] UNION ALL ...`` subquery aliased as ``src``."""
param_prefix = "row" if use_dual else "src_row"
per_row_selects: list[Select[Any]] = []
for idx, row in enumerate(rows):
row_columns: list[ColumnElement[Any]] = [
bindparam(f"{param_prefix}{idx}_{key}", value=row[key], type_=table.c[key].type).label(table.c[key].name)
for key in column_keys
]
row_select = select(*row_columns)
if use_dual:
row_select = row_select.select_from(text("DUAL"))
per_row_selects.append(row_select)
unified = per_row_selects[0] if len(per_row_selects) == 1 else per_row_selects[0].union_all(*per_row_selects[1:])
return unified.subquery("src")
def _build_values_merge_source(
table: Table,
rows: "Sequence[dict[str, Any]]",
column_keys: "Sequence[str]",
) -> ClauseElement:
"""Construct an MSSQL ``(VALUES (...)) AS src(...)`` clause with bound parameters.
Using ``text()`` with explicit :class:`~sqlalchemy.sql.expression.BindParameter`
children lets the MSSQL compiler translate the ``:row0_*`` markers to
``?`` placeholders that pyodbc understands. Column identifiers are
bracket-quoted (``[name]``) so reserved T-SQL keywords survive.
"""
quoted_cols = ["[" + table.c[key].name.replace("]", "]]") + "]" for key in column_keys]
col_names = ", ".join(quoted_cols)
bp_objects: list[Any] = []
row_fragments: list[str] = []
for idx, row in enumerate(rows):
placeholders: list[str] = []
for col_key in column_keys:
bp_name = f"row{idx}_{col_key}"
bp_objects.append(bindparam(bp_name, value=row[col_key], type_=table.c[col_key].type))
placeholders.append(f":{bp_name}")
row_fragments.append(f"({', '.join(placeholders)})")
return text(f"(VALUES {', '.join(row_fragments)}) AS src({col_names})").bindparams(*bp_objects)
def _build_merge_statement(
table: Table,
source: ClauseElement,
column_keys: "Sequence[str]",
conflict_columns: "Sequence[str]",
update_columns: "Sequence[str]",
) -> MergeStatement:
"""Assemble a :class:`MergeStatement` from a multi-row source clause."""
key_set = set(column_keys)
when_not_matched_insert: dict[str, Any] = {
table.c[key].name: _merge_source_column(table, key) for key in column_keys
}
when_matched_update: dict[str, Any] = {
table.c[col].name: _merge_source_column(table, col) for col in update_columns if col in key_set
}
on_condition = _MergeMatchCondition(_column_names(table, 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"})
_UPSERT_STRATEGY_CACHE: "WeakKeyDictionary[Table, dict[tuple[Any, ...], UpsertStrategy]]" = WeakKeyDictionary()
def _parse_dialect(dialect: Union[str, Dialect]) -> tuple[str, Optional[bool]]:
"""Extract ``(dialect_name, insert_returning)`` from a dialect name or ``Dialect`` instance."""
if isinstance(dialect, str):
return dialect, None
return dialect.name, bool(dialect.insert_returning)
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: 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``. This
includes ``match_fields`` entries that do not resolve to table columns
(mapped attributes whose name differs from the column key, synonyms,
hybrids) — the ORM fallback resolves those via ``getattr``.
Args:
table: Target table. Used by identity for caching.
match_fields: Columns the caller wants to match on. Order-insensitive.
dialect: 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.
"""
if not match_fields:
msg = "match_fields must not be empty"
raise ValueError(msg)
normalized = tuple(sorted(set(match_fields)))
resolved_dialect_name, insert_returning = _parse_dialect(dialect)
table_columns = set(table.c.keys())
if any(column_name not in table_columns for column_name in normalized):
return UpsertStrategy(
kind="fallback",
supports_returning=False,
conflict_columns=normalized,
dialect_name=resolved_dialect_name,
)
table_cache = _UPSERT_STRATEGY_CACHE.setdefault(table, {})
cache_key = (normalized, resolved_dialect_name, insert_returning)
strategy = table_cache.get(cache_key)
if strategy is None:
strategy = _resolve_upsert_strategy(table, normalized, resolved_dialect_name, insert_returning)
table_cache[cache_key] = strategy
return strategy
def _resolve_upsert_strategy(
table: Table,
match_fields: tuple[str, ...],
dialect_name: str,
insert_returning: Optional[bool],
) -> UpsertStrategy:
"""Resolution logic behind :func:`resolve_upsert_strategy`; results are memoized per 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,
)