Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Release History

# Unreleased

- Fix: `Enum` columns no longer raise `TypeError: String.__init__() got an unexpected keyword argument '_enums'`. `Enum` now resolves to a dedicated `DatabricksEnumType` instead of being adapted through the `String` colspec, keeping Enum validation and length inference while escaping literals the Databricks way (fixes #61)

# 2.0.10 (2026-06-18)

- Fix: Quote bind parameter names containing non-identifier characters (e.g. hyphens, backticks) so columns and parameters with special characters bind correctly (databricks/databricks-sqlalchemy#60 by @msrathore-db)
Expand Down
43 changes: 43 additions & 0 deletions src/databricks/sqlalchemy/_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,49 @@ def process(value):
return process


class DatabricksEnumType(sqlalchemy.types.Enum):
"""Enum columns, escaped the Databricks way.

``Enum`` is a subclass of ``String``, so without an entry of its own it
resolves through the ``String`` colspec above and SQLAlchemy adapts it to
``DatabricksStringType``. That adaptation raises
``TypeError: String.__init__() got an unexpected keyword argument
'_enums'``, because ``Enum.adapt()`` forwards its internal keyword
arguments and ``TypeDecorator.__init__`` passes them straight on to its
``impl`` (``String``), which does not accept them. Any operation touching
an Enum column therefore failed, including ``metadata.create_all()``.

Filtering those keywords out one at a time does not work — dropping
``_enums`` just moves the failure to ``_disable_warnings`` — and letting
Enum fall back to SQLAlchemy's own implementation reintroduces the
single-quote doubling that ``DatabricksStringType`` exists to avoid.

Subclassing ``Enum`` instead keeps every Enum behaviour that the generic
type provides (value validation under ``validate_strings``, length
inference from the longest value, native Python ``enum.Enum`` support)
while overriding only the literal rendering, so Enum literals are escaped
exactly as plain strings are.
"""

pe = ParamEscaper()
cache_ok = True

def literal_processor(self, dialect):
"""Escape Enum literals the same way ``DatabricksStringType`` does.

See that class for why the default ``String`` literal processing —
which doubles single-quotes — cannot be used against Databricks.
"""

def process(value):
_step1 = self.pe.escape_string(value)
if dialect.identifier_preparer._double_percents:
return _step1.replace("%", "%%")
return _step1

return process


class DatabricksUUID(sqlalchemy.types.Uuid):
"""Bind UUIDs in their canonical 8-4-4-4-12 hyphenated form.

Expand Down
4 changes: 4 additions & 0 deletions src/databricks/sqlalchemy/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,10 @@ class DatabricksDialect(default.DefaultDialect):
sqlalchemy.types.DateTime: dialect_type_impl.TIMESTAMP_NTZ,
sqlalchemy.types.Time: dialect_type_impl.DatabricksTimeType,
sqlalchemy.types.String: dialect_type_impl.DatabricksStringType,
# Enum subclasses String, so without an entry of its own it would
# resolve to DatabricksStringType and fail to adapt. See
# DatabricksEnumType.
sqlalchemy.types.Enum: dialect_type_impl.DatabricksEnumType,
sqlalchemy.types.Uuid: dialect_type_impl.DatabricksUUID,
}

Expand Down
91 changes: 91 additions & 0 deletions tests/test_local/test_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,12 @@
import pytest
import sqlalchemy
from sqlalchemy import Column, MetaData, Table, select
from sqlalchemy.schema import CreateTable

from databricks.sqlalchemy.base import DatabricksDialect
from databricks.sqlalchemy._types import (
DatabricksEnumType,
DatabricksStringType,
DatabricksUUID,
DatabricksVariant,
TINYINT,
Expand Down Expand Up @@ -366,3 +369,91 @@ def test_as_uuid_false_round_trip_normalizes_hex_input(self):
assert bind(self.HYPHENATED) == self.HYPHENATED
assert result(self.HYPHENATED) == self.HYPHENATED
assert result(self.HEX) == self.HYPHENATED


class Colour(enum.Enum):
"""A native Python enum, the other way users declare Enum columns."""

RED = "red"
BLUE = "blue"


class TestDatabricksEnum:
"""Regression coverage for github.com/databricks/databricks-sqlalchemy/issues/61.

``Enum`` subclasses ``String``, so before this fix it resolved through the
``String`` colspec and SQLAlchemy tried to adapt it to
``DatabricksStringType``. ``Enum.adapt()`` forwards internal keyword
arguments that ``String`` does not accept, so every operation touching an
Enum column raised ``TypeError: String.__init__() got an unexpected
keyword argument '_enums'``.
"""

dialect = DatabricksDialect()
TRICKY = "O'Bri\\en"

def test_dialect_routes_enum_to_databricks_enum(self):
"""The colspecs entry is what keeps Enum off the String path."""
assert self.dialect.colspecs[sqlalchemy.types.Enum] is DatabricksEnumType

def test_enum_column_adapts_without_type_error(self):
"""The reported crash: adapting an Enum blew up before it could compile."""
assert sqlalchemy.Enum("A", "B", "C").dialect_impl(self.dialect) is not None

def test_create_table_with_enum_column_compiles(self):
meta = MetaData()
table = Table(
"my_table",
meta,
Column("status", sqlalchemy.Enum("A", "B", "C"), nullable=False),
)

ddl = str(CreateTable(table).compile(dialect=self.dialect))

assert "status STRING NOT NULL" in ddl

def test_native_python_enum_column_compiles(self):
meta = MetaData()
table = Table("my_table", meta, Column("colour", sqlalchemy.Enum(Colour)))

ddl = str(CreateTable(table).compile(dialect=self.dialect))

assert "colour STRING" in ddl

def test_enum_literal_is_escaped_like_a_plain_string(self):
"""Enum literals must use backslash escaping, not SQLAlchemy's doubling.

Letting Enum fall through to SQLAlchemy's own implementation would
render ``'O''Bri\\en'``, which is precisely the breakage
``DatabricksStringType`` exists to prevent.
"""
enum_process = DatabricksEnumType("A", self.TRICKY).literal_processor(
self.dialect
)
string_process = DatabricksStringType().literal_processor(self.dialect)

assert enum_process(self.TRICKY) == string_process(self.TRICKY)
assert "''" not in enum_process(self.TRICKY)

def test_enum_length_is_inferred_from_longest_value(self):
impl = sqlalchemy.Enum("A", "LONGER_VALUE").dialect_impl(self.dialect)

assert impl.length == len("LONGER_VALUE")

def test_validate_strings_still_rejects_unknown_values(self):
"""Subclassing Enum keeps its validation; wrapping it in a TypeDecorator did not."""
impl = sqlalchemy.Enum("A", "B", validate_strings=True).dialect_impl(
self.dialect
)
process = impl.bind_processor(self.dialect)

assert process("A") == "A"
with pytest.raises(LookupError):
process("ZZZ")

def test_plain_string_columns_are_unaffected(self):
"""The Enum entry must not divert ordinary String columns."""
assert self.dialect.colspecs[sqlalchemy.types.String] is DatabricksStringType
assert isinstance(
sqlalchemy.String(50).dialect_impl(self.dialect), DatabricksStringType
)