Skip to content

Commit 3089ece

Browse files
jackobermancursoragentclaudeAndreasAlbertQCCopilot
authored
feat: Add optional sqlalchemy_use_enum for dy.Enum (#355)
Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Andreas Albert <103571926+AndreasAlbertQC@users.noreply.github.com> Co-authored-by: Andreas Albert <andreas.albert@quantco.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1 parent d3a217e commit 3089ece

4 files changed

Lines changed: 197 additions & 1 deletion

File tree

dataframely/columns/enum.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ def __init__(
3333
alias: str | None = None,
3434
metadata: dict[str, Any] | None = None,
3535
description: str | None = None,
36+
sqlalchemy_use_enum: bool = False,
37+
sqlalchemy_enum_name: str | None = None,
3638
):
3739
"""
3840
Args:
@@ -68,6 +70,15 @@ def __init__(
6870
names, the specified alias is the only valid name.
6971
metadata: A dictionary of metadata to attach to the column.
7072
description: A human-readable description of the column.
73+
sqlalchemy_use_enum: When ``True``, map this column to :class:`sqlalchemy.Enum`
74+
in :meth:`~dataframely.Schema.to_sqlalchemy_columns` instead of
75+
``CHAR`` / ``VARCHAR``.
76+
sqlalchemy_enum_name: Optional name for the SQLAlchemy / database enum type
77+
when ``sqlalchemy_use_enum=True``. If omitted and ``categories`` is a
78+
Python :class:`enum.Enum` subclass, the lowercased enum class is used.
79+
Otherwise, the name of the column is used.
80+
The persisted values are the enum members' ``.value`` strings (not
81+
member names), matching :attr:`categories`.
7182
"""
7283
super().__init__(
7384
nullable=nullable,
@@ -78,8 +89,26 @@ def __init__(
7889
metadata=metadata,
7990
description=description,
8091
)
92+
if sqlalchemy_enum_name and not sqlalchemy_use_enum:
93+
raise ValueError(
94+
"`sqlalchemy_enum_name` has no effect when `sqlalchemy_use_enum=False`."
95+
)
96+
97+
self.sqlalchemy_use_enum = sqlalchemy_use_enum
98+
self.sqlalchemy_enum_name = sqlalchemy_enum_name
8199
if isclass(categories) and issubclass(categories, enum.Enum):
100+
# If the user passed an Enum type, we want to determine a default name
101+
# based on the Enum class name, which is also what sqlalchemy does.
102+
# One could instead keep a reference to the Enum class around and pass it
103+
# to sqlalchemy later on, but that will interfere with the base-class implementations
104+
# of `matches` and `to_dict` / `from_dict`.
105+
if self.sqlalchemy_use_enum:
106+
self.sqlalchemy_enum_name = (
107+
self.sqlalchemy_enum_name or categories.__name__.lower()
108+
)
109+
82110
categories = (item.value for item in categories)
111+
83112
self.categories = list(categories)
84113

85114
@property
@@ -92,6 +121,10 @@ def validate_dtype(self, dtype: PolarsDataType) -> bool:
92121
return self.categories == dtype.categories.to_list()
93122

94123
def sqlalchemy_dtype(self, dialect: sa.Dialect) -> sa_TypeEngine:
124+
if self.sqlalchemy_use_enum:
125+
return sa.Enum(
126+
*self.categories, name=self.sqlalchemy_enum_name or self._name
127+
)
95128
category_lengths = [len(c) for c in self.categories]
96129
if all(length == category_lengths[0] for length in category_lengths):
97130
return sa.CHAR(category_lengths[0])

docs/guides/features/sql-generation.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,41 @@ the maximal length of the string is inferred from the regular expression if poss
8181
maximal lengths can be particularly important for primary key columns. Some database systems, such as Microsoft SQL Server, do not allow `VARCHAR(max)` columns (unbounded strings) to be used as primary keys.
8282
```
8383

84+
## Native SQL enums
85+
86+
By default, {class}`~dataframely.Enum` maps to `sa.CHAR` or `sa.String` columns so stored values remain plain strings. You may set `sqlalchemy_use_enum=True` to instead generate native enums:
87+
88+
```python
89+
from enum import Enum, auto
90+
91+
import sqlalchemy as sa
92+
import dataframely as dy
93+
from sqlalchemy.dialects.postgresql import dialect as pg_dialect
94+
from sqlalchemy.dialects.mssql import dialect as mssql_dialect
95+
96+
97+
class Status(str, Enum):
98+
PENDING = auto()
99+
APPROVED = auto()
100+
101+
102+
class Staged(dy.Schema):
103+
status = dy.Enum(Status, sqlalchemy_use_enum=True)
104+
```
105+
106+
This will translate the `~dataframely.Enum` to a `~sqlalchemy.Enum`:
107+
108+
```python
109+
>>> Staged.to_sqlalchemy_columns(pg_dialect())
110+
[Column('status', Enum('1', '2', name='status'), table=None, nullable=False)]
111+
```
112+
113+
Depending on the database dialect you use, `sqlalchemy` will render this accordingly.
114+
For example, `postgresql` supports native enums, and `sqlalchemy` will create a native enum column, while in MSSQL, where this is not supported, it will fall back to `VARCHAR`.
115+
116+
When `categories` is a Python `enum.Enum` subclass, `sqlalchemy` uses the enum class name (lowercased) as the database enum type name.
117+
For string category lists, the SQL column name is used by default; override it with `sqlalchemy_enum_name` if needed.
118+
84119
## Collections of multiple tables
85120

86121
If you have an entire `dy.Collection`, it's also easy to generate one table for each member table of the collection.

tests/column_types/test_enum.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,3 +108,69 @@ def test_sequences_and_enums(
108108
S = create_schema("test", {"x": dy.Enum(categories1)})
109109
df = pl.DataFrame({"x": pl.Series(["a", "b"], dtype=pl.Enum(categories2))})
110110
S.validate(df)
111+
112+
113+
def test_matches_sqlalchemy_use_enum() -> None:
114+
expr = pl.element()
115+
assert dy.Enum(["a", "b"]).matches(dy.Enum(["a", "b"]), expr)
116+
assert not dy.Enum(["a", "b"], sqlalchemy_use_enum=True).matches(
117+
dy.Enum(["a", "b"]), expr
118+
)
119+
assert dy.Enum(["a", "b"], sqlalchemy_use_enum=True).matches(
120+
dy.Enum(["a", "b"], sqlalchemy_use_enum=True), expr
121+
)
122+
123+
124+
def test_matches_sqlalchemy_use_enum_fails_on_internal_name_mismatch() -> None:
125+
class MyEnum(str, Enum):
126+
x = "x"
127+
128+
assert not dy.Enum(MyEnum, sqlalchemy_use_enum=True).matches(
129+
dy.Enum(["x"], sqlalchemy_use_enum=True), pl.element()
130+
)
131+
132+
133+
def test_matches_sqlalchemy_enum_name() -> None:
134+
expr = pl.element()
135+
assert dy.Enum(
136+
["a", "b"],
137+
sqlalchemy_use_enum=True,
138+
sqlalchemy_enum_name="one",
139+
).matches(
140+
dy.Enum(
141+
["a", "b"],
142+
sqlalchemy_use_enum=True,
143+
sqlalchemy_enum_name="one",
144+
),
145+
expr,
146+
)
147+
assert not dy.Enum(
148+
["a", "b"],
149+
sqlalchemy_use_enum=True,
150+
sqlalchemy_enum_name="one",
151+
).matches(
152+
dy.Enum(
153+
["a", "b"],
154+
sqlalchemy_use_enum=True,
155+
sqlalchemy_enum_name="two",
156+
),
157+
expr,
158+
)
159+
160+
161+
def test_sqlalchemy_enum_name_without_use_enum_raises() -> None:
162+
with pytest.raises(ValueError, match="`sqlalchemy_enum_name` has no effect"):
163+
dy.Enum(["a", "b"], sqlalchemy_enum_name="my_enum")
164+
165+
166+
def test_as_dict_from_dict_sqlalchemy_enum_flags() -> None:
167+
column = dy.Enum(
168+
["a", "b"],
169+
sqlalchemy_use_enum=True,
170+
sqlalchemy_enum_name="my_enum",
171+
)
172+
data = column.as_dict(pl.element())
173+
restored = dy.Enum.from_dict(data)
174+
assert restored.sqlalchemy_use_enum is True
175+
assert restored.sqlalchemy_enum_name == "my_enum"
176+
assert restored.categories == ["a", "b"]

tests/columns/test_sqlalchemy_columns.py

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
# Copyright (c) QuantCo 2025-2026
22
# SPDX-License-Identifier: BSD-3-Clause
33

4+
from enum import Enum
5+
from typing import cast
6+
47
import pytest
58

69
import dataframely as dy
7-
from dataframely._compat import Dialect, MSDialect_pyodbc, PGDialect_psycopg2
10+
from dataframely._compat import Dialect, MSDialect_pyodbc, PGDialect_psycopg2, sa
811
from dataframely.columns import Column
912
from dataframely.testing import COLUMN_TYPES, create_schema
1013

@@ -171,3 +174,62 @@ def test_raise_for_object_column(dialect: Dialect) -> None:
171174
NotImplementedError, match="SQL column cannot have 'Object' type."
172175
):
173176
dy.Object().sqlalchemy_dtype(dialect)
177+
178+
179+
class _Status(str, Enum):
180+
PENDING = "pending"
181+
APPROVED = "approved"
182+
183+
184+
@pytest.mark.parametrize(
185+
("column", "dialect", "datatype"),
186+
[
187+
(
188+
dy.Enum(["foo", "bar"], sqlalchemy_use_enum=True),
189+
PGDialect_psycopg2(),
190+
"a",
191+
),
192+
(
193+
dy.Enum(
194+
["foo", "bar"],
195+
sqlalchemy_use_enum=True,
196+
sqlalchemy_enum_name="my_status",
197+
),
198+
PGDialect_psycopg2(),
199+
"my_status",
200+
),
201+
(dy.Enum(_Status, sqlalchemy_use_enum=True), PGDialect_psycopg2(), "_status"),
202+
(
203+
dy.Enum(["foo", "bar"], sqlalchemy_use_enum=True),
204+
MSDialect_pyodbc(),
205+
"VARCHAR(3)",
206+
),
207+
],
208+
)
209+
def test_enum_sqlalchemy_native(
210+
column: Column, dialect: Dialect, datatype: str
211+
) -> None:
212+
schema = create_schema("test", {"a": column})
213+
columns = schema.to_sqlalchemy_columns(dialect)
214+
assert len(columns) == 1
215+
assert columns[0].type.compile(dialect) == datatype
216+
217+
218+
def test_enum_sqlalchemy_native_python_enum_uses_member_values() -> None:
219+
column = dy.Enum(_Status, sqlalchemy_use_enum=True)
220+
schema = create_schema("test", {"a": column})
221+
sa_type = cast(
222+
sa.sql.sqltypes.Enum, schema.to_sqlalchemy_columns(PGDialect_psycopg2())[0].type
223+
)
224+
assert list(sa_type.enums) == column.categories
225+
226+
227+
def test_enum_sqlalchemy_native_string_categories_use_column_name() -> None:
228+
class TestSchema(dy.Schema):
229+
status = dy.Enum(["foo", "bar"], sqlalchemy_use_enum=True)
230+
231+
column = TestSchema.columns()["status"]
232+
assert (
233+
column.sqlalchemy_dtype(PGDialect_psycopg2()).compile(PGDialect_psycopg2())
234+
== "status"
235+
)

0 commit comments

Comments
 (0)