diff --git a/CHANGELOG.md b/CHANGELOG.md index e744830..324e4c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/src/databricks/sqlalchemy/_types.py b/src/databricks/sqlalchemy/_types.py index c180404..a3d600e 100644 --- a/src/databricks/sqlalchemy/_types.py +++ b/src/databricks/sqlalchemy/_types.py @@ -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. diff --git a/src/databricks/sqlalchemy/base.py b/src/databricks/sqlalchemy/base.py index bcdd6a8..c21edec 100644 --- a/src/databricks/sqlalchemy/base.py +++ b/src/databricks/sqlalchemy/base.py @@ -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, } diff --git a/tests/test_local/test_types.py b/tests/test_local/test_types.py index 7036c8d..dc720f4 100644 --- a/tests/test_local/test_types.py +++ b/tests/test_local/test_types.py @@ -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, @@ -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 + )