Skip to content

Support YDB native UUID type #86

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
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
25 changes: 25 additions & 0 deletions test/test_core.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import asyncio
import datetime
import uuid
from decimal import Decimal
from typing import NamedTuple

Expand All @@ -11,6 +12,7 @@
from ydb._grpc.v4.protos import ydb_common_pb2

from ydb_sqlalchemy import IsolationLevel, dbapi

from ydb_sqlalchemy import sqlalchemy as ydb_sa
from ydb_sqlalchemy.sqlalchemy import types

Expand Down Expand Up @@ -238,6 +240,13 @@ def define_tables(cls, metadata: sa.MetaData):
Column("date", sa.Date),
# Column("interval", sa.Interval),
)
Table(
"test_uuid_types",
metadata,
Column("id", Integer, primary_key=True),
Column("uuid_native", sa.UUID),
Column("uuid_str", sa.Uuid),
)

def test_primitive_types(self, connection):
table = self.tables.test_primitive_types
Expand Down Expand Up @@ -310,6 +319,22 @@ def test_datetime_types_timezone(self, connection):
today,
)

def test_uuid_types(self, connection):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: Let's try to unskip uuid tests in suite by removing @pytest.mark.skip("uuid unsupported for columns")

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (non-blocking): Consider to add test that ydb.PrimitiveType.UUID column is correctly reflected to sa.UUID by calling metadata.reflect()

table = self.tables.test_uuid_types
uuid_value = uuid.uuid4()

statement = sa.insert(table).values(id=1, uuid_native=uuid_value, uuid_str=uuid_value)
connection.execute(statement)
row = connection.execute(sa.select(table).where(table.c.id == 1)).fetchone()
assert row == (1, uuid_value, uuid_value)

uuid_value_str = str(uuid_value)

statement = sa.insert(table).values(id=2, uuid_native=uuid_value_str, uuid_str=uuid_value)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: Shouldn't here be also uuid_str=uuid_value_str?

connection.execute(statement)
row = connection.execute(sa.select(table).where(table.c.id == 2)).fetchone()
assert row == (2, uuid_value, uuid_value)


class TestWithClause(TablesTest):
__backend__ = True
Expand Down
2 changes: 2 additions & 0 deletions ydb_sqlalchemy/sqlalchemy/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ def upsert(table):
ydb.PrimitiveType.Interval: sa.INTEGER,
ydb.PrimitiveType.Bool: sa.BOOLEAN,
ydb.PrimitiveType.DyNumber: sa.TEXT,
ydb.PrimitiveType.UUID: sa.UUID,
}


Expand Down Expand Up @@ -140,6 +141,7 @@ class YqlDialect(StrCompileDialect):
sa.types.DateTime: types.YqlTimestamp, # Because YDB's DateTime doesn't store microseconds
sa.types.DATETIME: types.YqlDateTime,
sa.types.TIMESTAMP: types.YqlTimestamp,
sa.types.UUID: types.YqlUUID,
}

connection_characteristics = util.immutabledict(
Expand Down
10 changes: 10 additions & 0 deletions ydb_sqlalchemy/sqlalchemy/compiler/sa20.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,19 +11,29 @@
BaseYqlIdentifierPreparer,
BaseYqlTypeCompiler,
)
from .. import types
from typing import Union


class YqlTypeCompiler(BaseYqlTypeCompiler):
def visit_uuid(self, type_: sa.Uuid, **kw):
return "UTF8"

def visit_UUID(self, type_: Union[sa.UUID, types.YqlUUID], **kw):
return "UUID"

def get_ydb_type(
self, type_: sa.types.TypeEngine, is_optional: bool
) -> Union[ydb.PrimitiveType, ydb.AbstractTypeBuilder]:
if isinstance(type_, sa.TypeDecorator):
type_ = type_.impl

if isinstance(type_, sa.UUID):
ydb_type = ydb.PrimitiveType.UUID
if is_optional:
return ydb.OptionalType(ydb_type)
return ydb_type

if isinstance(type_, sa.Uuid):
ydb_type = ydb.PrimitiveType.Utf8
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: Since sa.Uuid is subtype of sa.UUID this block is unreachable. I think we need to consider sa.Uuid also as ydb.PrimitiveType.UUID, because sqlalchemy is designed in this way:

class Uuid(Emulated, TypeEngine[_UUID_RETURN]):
    """Represent a database agnostic UUID datatype.

    For backends that have no "native" UUID datatype, the value will
    make use of ``CHAR(32)`` and store the UUID as a 32-character alphanumeric
    hex string.

    For backends which are known to support ``UUID`` directly or a similar
    uuid-storing datatype such as SQL Server's ``UNIQUEIDENTIFIER``, a
    "native" mode enabled by default allows these types will be used on those
    backends.

Though it is not backward compatible for sure.

if is_optional:
Expand Down
22 changes: 22 additions & 0 deletions ydb_sqlalchemy/sqlalchemy/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,28 @@
from .json import YqlJSON # noqa: F401


class YqlUUID(types.UUID):
__visit_name__ = "UUID"

def bind_processor(self, dialect):
def process(value):
if value is None:
return None
if isinstance(value, str):
try:
import uuid as uuid_module

value = uuid_module.UUID(value)
Comment on lines +26 to +28
Copy link
Preview

Copilot AI Jul 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] Consider moving the import uuid statement to the module level to avoid repeated imports inside the bind processor, improving clarity and performance.

Suggested change
import uuid as uuid_module
value = uuid_module.UUID(value)
value = uuid.UUID(value)

Copilot uses AI. Check for mistakes.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bump

except ValueError:
raise ValueError(f"Invalid UUID string: {value}")
return value

return process

def result_processor(self, dialect, coltype):
return None
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: Why return None? Don't we need to either implement or to not touch this method?



Comment on lines +36 to +38
Copy link
Preview

Copilot AI Jul 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider implementing a result_processor to convert raw returned values (e.g., strings) back into uuid.UUID objects, ensuring consistent Python types.

Suggested change
return None
def process(value):
if value is None:
return None
if isinstance(value, str):
try:
import uuid as uuid_module
return uuid_module.UUID(value)
except ValueError:
raise ValueError(f"Invalid UUID string: {value}")
return value
return process

Copilot uses AI. Check for mistakes.

class UInt64(types.Integer):
__visit_name__ = "uint64"

Expand Down