From 0476432ddca57b221037ac588303327cbbcfc705 Mon Sep 17 00:00:00 2001 From: TangoEnSkai <21152231+TangoEnSkai@users.noreply.github.com> Date: Sun, 23 Aug 2026 14:32:59 +0900 Subject: [PATCH] fix: stop Enum columns raising TypeError on _enums MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Any operation touching an Enum column failed with TypeError: String.__init__() got an unexpected keyword argument '_enums' Enum subclasses String, so with no colspecs entry of its own it resolved through the String entry and SQLAlchemy adapted it to DatabricksStringType. That class is a TypeDecorator whose __init__ hands its arguments straight to its impl (String), while Enum.adapt() forwards Enum's internal keyword arguments — so String received _enums and rejected it. metadata.create_all() on a model with an Enum column could not run. Two approaches were tried and rejected before this one: Filtering the offending keyword out is whack-a-mole. Dropping _enums just moves the failure to _disable_warnings, and the set of forwarded keywords is SQLAlchemy's to change. Letting Enum fall back to SQLAlchemy's own implementation (mapping it to sqlalchemy.types.Enum) fixes the crash but renders literals with doubled single-quotes — 'O''Brien' rather than 'O\'Brien' — which is exactly the breakage DatabricksStringType was written to prevent. Subclassing Enum keeps every behaviour of the generic type (value validation under validate_strings, length inference from the longest value, native Python enum.Enum support) and overrides only literal_processor, so Enum literals are escaped exactly as plain strings are. Verified against the SQLite dialect as a reference: validation and default behaviour now match it. The existing camel_case_type_map covers Enum but did not catch this, because it asserts on Enum(...).compile(dialect), which renders the type name (STRING) without going through colspecs adaptation. Only dialect_impl() — the path every real query and DDL run takes — hit the crash. The new tests exercise that path. Resolves #61 Signed-off-by: TangoEnSkai <21152231+TangoEnSkai@users.noreply.github.com> --- CHANGELOG.md | 4 ++ src/databricks/sqlalchemy/_types.py | 43 ++++++++++++++ src/databricks/sqlalchemy/base.py | 4 ++ tests/test_local/test_types.py | 91 +++++++++++++++++++++++++++++ 4 files changed, 142 insertions(+) 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 + )