Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ jobs:
max-parallel: 4
matrix:
python-version: [3.9]
environment: [test-unit, test-dialect]
environment: [test-unit, test-dialect, test-unit-sa14, test-dialect-sa14]

steps:
- uses: actions/checkout@v1
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
* Fix SQLAlchemy 1.4 compatibility and test the full dialect against SQLAlchemy 1.4.54
* Add opt-in support for YDB's native UUID type via `sa.UUID` or `types.YqlUUID` while keeping `sa.Uuid` stored as `Utf8`

## 0.1.24 ##
Expand Down
4 changes: 2 additions & 2 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,12 @@ Welcome to the YDB SQLAlchemy dialect documentation. This package provides a SQL
Overview
--------

YDB SQLAlchemy is a dialect that enables SQLAlchemy to work with YDB databases. It supports both SQLAlchemy 2.0 (fully tested) and SQLAlchemy 1.4 (partially tested).
YDB SQLAlchemy is a dialect that enables SQLAlchemy to work with YDB databases. It supports and tests both SQLAlchemy 1.4 and 2.0.

Key Features:
~~~~~~~~~~~~~

* **SQLAlchemy 2.0 Support**: Full compatibility with the latest SQLAlchemy version
* **SQLAlchemy 1.4 and 2.0 Support**: Core, ORM, synchronous, and asynchronous scenarios are tested on both release lines
* **Async/Await Support**: Full async support with ``yql+ydb_async`` dialect
* **Core and ORM**: Support for both SQLAlchemy Core and ORM patterns
* **Authentication**: Multiple authentication methods including static credentials, tokens, and service accounts
Expand Down
2 changes: 1 addition & 1 deletion docs/installation.rst
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ Requirements
------------

* Python 3.7 or higher
* SQLAlchemy 1.4+ or 2.0+ (recommended)
* SQLAlchemy 1.4 or 2.0
* YDB Python SDK

Installing from PyPI
Expand Down
1 change: 0 additions & 1 deletion test-requirements.txt
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
pyyaml==5.3.1
greenlet

sqlalchemy==2.0.7
ydb >= 3.18.8
ydb-dbapi >= 0.1.1
# 1.14 added DefaultImpl.version_table_impl, which ydb_sqlalchemy.alembic overrides
Expand Down
27 changes: 17 additions & 10 deletions tests/integration/test_alembic.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,9 +135,16 @@ def sync_url():
return url


def commit(connection) -> None:
if hasattr(connection, "commit"):
connection.commit()
else:
connection.connection.commit()


@pytest.fixture
def engine(sync_url):
engine = sa.create_engine(sync_url, poolclass=sa.pool.NullPool)
engine = sa.create_engine(sync_url, poolclass=sa.pool.NullPool, future=True)
yield engine
engine.dispose()

Expand All @@ -157,7 +164,7 @@ def drop_tables(engine, *names) -> None:
continue
with engine.connect() as conn:
sa.Table(name, sa.MetaData()).drop(conn)
conn.commit()
commit(conn)


class AlembicEnv:
Expand Down Expand Up @@ -612,10 +619,10 @@ def test_create_index_unique_is_not_enforced(self, migration_ctx, engine, table_

with engine.connect() as conn:
conn.execute(sa.text(f"UPSERT INTO `{table_name}` (id, name) VALUES (1, 'dup')"))
conn.commit()
commit(conn)
with engine.connect() as conn:
conn.execute(sa.text(f"UPSERT INTO `{table_name}` (id, name) VALUES (2, 'dup')"))
conn.commit()
commit(conn)
with engine.connect() as conn:
rows = conn.execute(sa.text(f"SELECT name FROM `{table_name}` WHERE name = 'dup'")).fetchall()
assert len(rows) == 2, "the duplicate would be rejected if the index were unique"
Expand Down Expand Up @@ -858,14 +865,14 @@ def test_no_diff_when_model_matches_database(self, engine, table_name):
metadata = self._model(table_name)
with engine.connect() as conn:
metadata.create_all(conn)
conn.commit()
commit(conn)

assert self._diff(engine, metadata, table_name) == []

def test_detects_added_column(self, engine, table_name):
with engine.connect() as conn:
self._model(table_name).create_all(conn)
conn.commit()
commit(conn)

diff = self._diff(engine, self._model(table_name, extra_column=True), table_name)

Expand All @@ -875,7 +882,7 @@ def test_detects_added_column(self, engine, table_name):
def test_detects_removed_column(self, engine, table_name):
with engine.connect() as conn:
self._model(table_name).create_all(conn)
conn.commit()
commit(conn)

diff = self._diff(engine, self._model(table_name, drop_name=True), table_name)

Expand All @@ -885,7 +892,7 @@ def test_detects_removed_column(self, engine, table_name):
def test_detects_added_index(self, engine, table_name):
with engine.connect() as conn:
self._model(table_name).create_all(conn)
conn.commit()
commit(conn)

diff = self._diff(engine, self._model(table_name, index=True), table_name)

Expand All @@ -895,7 +902,7 @@ def test_detects_added_index(self, engine, table_name):
def test_detects_removed_index(self, engine, table_name):
with engine.connect() as conn:
self._model(table_name, index=True).create_all(conn)
conn.commit()
commit(conn)

diff = self._diff(engine, self._model(table_name), table_name)

Expand All @@ -905,7 +912,7 @@ def test_detects_removed_index(self, engine, table_name):
def test_detects_removed_table(self, engine, table_name):
with engine.connect() as conn:
self._model(table_name).create_all(conn)
conn.commit()
commit(conn)

diff = self._diff(engine, sa.MetaData(), table_name)

Expand Down
10 changes: 6 additions & 4 deletions tests/integration/test_core.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from __future__ import annotations

import asyncio
import datetime
import uuid
Expand Down Expand Up @@ -119,7 +121,7 @@ def test_cached_query(self, connection_no_trans, connection):

def test_sa_crud_with_add_declare(self):
engine = sa.create_engine(config.db_url, _add_declare_for_yql_stmt_vars=True)
with engine.connect() as connection:
with engine.begin() as connection:
self.test_sa_crud(connection)


Expand Down Expand Up @@ -562,17 +564,16 @@ def define_tables(cls, metadata: sa.MetaData):
Column("id", Integer, primary_key=True),
)

@pytest.mark.skipif(sa.__version__ < "2.", reason="Something was different in SA<2, good to fix")
def test_rollback(self, connection_no_trans, connection):
table = self.tables.test

connection_no_trans.execution_options(isolation_level=IsolationLevel.SERIALIZABLE)
with connection_no_trans.begin():
with connection_no_trans.begin() as transaction:
stm1 = table.insert().values(id=1)
connection_no_trans.execute(stm1)
stm2 = table.insert().values(id=2)
connection_no_trans.execute(stm2)
connection_no_trans.rollback()
transaction.rollback()

cursor = connection.execute(sa.select(table))
result = cursor.fetchall()
Expand Down Expand Up @@ -1107,6 +1108,7 @@ def test_index_with_join_usage(self, connection, metadata: sa.MetaData):
)
.select_from(persons)
.with_hint(persons, "VIEW `ix_tax_number_cover_full_name`")
.subquery()
)
select_stmt = (
sa.select(persons_indexed.c.full_name, person_status.c.status)
Expand Down
21 changes: 15 additions & 6 deletions tests/integration/test_suite.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import ctypes
import datetime
import decimal
import uuid

import pytest
import sqlalchemy as sa
Expand Down Expand Up @@ -112,6 +113,10 @@ def _check_list(self, result, exp, req_keys=None, msg=None):
return "We changed primary_keys in define_reflected_tables method so this will fail"
raise

@pytest.mark.skip("YDB index reflection is covered by TestSecondaryIndex")
def test_get_indexes(self, connection, use_schema):
pass

@classmethod
def define_reflected_tables(cls, metadata, schema):
Table(
Expand Down Expand Up @@ -240,14 +245,18 @@ def define_tables(cls, metadata):
def test_has_table_cache(self, metadata):
insp = inspect(config.db)
is_true(insp.has_table("test_table"))
# table without pk unsupported
nt = Table("new_table", metadata, Column("col", Integer, primary_key=True))
is_false(insp.has_table("new_table"))
table_name = f"ydb_has_table_cache_{uuid.uuid4().hex[:8]}"
nt = Table(table_name, metadata, Column("col", Integer, primary_key=True))
is_false(insp.has_table(table_name))
nt.create(config.db)
try:
is_false(insp.has_table("new_table"))
insp.clear_cache()
is_true(insp.has_table("new_table"))
if OLD_SA:
# Inspector.has_table() did not use its info cache in 1.4.
is_true(insp.has_table(table_name))
else:
is_false(insp.has_table(table_name))
insp.clear_cache()
is_true(insp.has_table(table_name))
finally:
nt.drop(config.db)

Expand Down
24 changes: 23 additions & 1 deletion tox.ini
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[tox]
envlist = test,test-all,test-dialect,test-unit,black,black-format,style,coverage
envlist = test,test-all,test-dialect,test-unit,test-dialect-sa14,test-unit-sa14,black,black-format,style,coverage
minversion = 4.2.6
skipsdist = True
ignore_basepython_conflict = true
Expand All @@ -11,6 +11,7 @@ setenv =
PYTHONPATH = {env:PYTHONPATH}{:}{toxinidir}
deps =
-r{toxinidir}/test-requirements.txt
sqlalchemy==2.0.7

[testenv:test]
ignore_errors = True
Expand Down Expand Up @@ -40,6 +41,27 @@ commands =
commands =
pytest -v {toxinidir}/ydb_sqlalchemy

[testenv:test-dialect-sa14]
basepython = python3.9
deps =
-r{toxinidir}/test-requirements.txt
sqlalchemy==1.4.54
commands =
python -c "import sqlalchemy; assert sqlalchemy.__version__ == '1.4.54'; print('SQLAlchemy', sqlalchemy.__version__)"
docker-compose up -d
python {toxinidir}/wait_container_ready.py
pytest -v tests/integration --dbdriver ydb --dbdriver ydb_async
docker-compose down -v

[testenv:test-unit-sa14]
basepython = python3.9
deps =
-r{toxinidir}/test-requirements.txt
sqlalchemy==1.4.54
commands =
python -c "import sqlalchemy; assert sqlalchemy.__version__ == '1.4.54'; print('SQLAlchemy', sqlalchemy.__version__)"
pytest -v {toxinidir}/ydb_sqlalchemy

[testenv:coverage]
ignore_errors = True
commands =
Expand Down
9 changes: 8 additions & 1 deletion ydb_sqlalchemy/sqlalchemy/compiler/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -371,7 +371,7 @@ def visit_concat_func(self, func, **kwargs):
return arg_sql

def _is_bound_to_nullable_column(self, bind_name: str) -> bool:
if bind_name in self.column_keys and hasattr(self.compile_state, "dml_table"):
if self.column_keys and bind_name in self.column_keys and hasattr(self.compile_state, "dml_table"):
if bind_name in self.compile_state.dml_table.c:
column = self.compile_state.dml_table.c[bind_name]
# Lightweight constructs built with sa.table()/sa.column() -- the form
Expand All @@ -391,6 +391,13 @@ def _guess_bound_variable_type_by_parameters(
not_null_values = [v for v in post_compile_bind_values if v is not None]
if not_null_values:
bind_type = _bindparam("", not_null_values[0]).type
elif isinstance(bind_type, sa.TypeDecorator) and bind_type._has_bind_expression:
# The bind expression describes the target of a SQL-side cast, not
# the type of the value sent to YDB. YDB parameters are strongly
# typed, so derive their source type from the runtime value.
not_null_values = [v for v in post_compile_bind_values if v is not None]
if not_null_values:
bind_type = _bindparam("", not_null_values[0]).type

if isinstance(bind_type, sa.types.NullType):
return None
Expand Down
49 changes: 49 additions & 0 deletions ydb_sqlalchemy/sqlalchemy/compiler/sa14.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@
import sqlalchemy as sa
import ydb

from sqlalchemy.exc import CompileError
from sqlalchemy.sql import literal_column
from sqlalchemy.util.compat import inspect_getfullargspec

from .base import (
BaseYqlCompiler,
BaseYqlDDLCompiler,
Expand Down Expand Up @@ -37,6 +41,51 @@ class YqlIdentifierPreparer(BaseYqlIdentifierPreparer):
class YqlCompiler(BaseYqlCompiler):
_type_compiler_cls = YqlTypeCompiler

def visit_json_getitem_op_binary(self, binary, operator, **kw):
json_field = self.process(binary.left, **kw)
index = self.process(binary.right, **kw)
return self._yson_convert_to(f"{json_field}[{index}]", binary.type)

def visit_json_path_getitem_op_binary(self, binary, operator, **kw):
json_field = self.process(binary.left, **kw)
path = self.process(binary.right, **kw)
return self._yson_convert_to(f"Yson::YPath({json_field}, {path})", binary.type)

def visit_regexp_match_op_binary(self, binary, operator, **kw):
return self._generate_generic_binary(binary, " REGEXP ", **kw)

def visit_not_regexp_match_op_binary(self, binary, operator, **kw):
return self._generate_generic_binary(binary, " NOT REGEXP ", **kw)

def visit_lambda(self, lambda_, **kw):
func = lambda_.func
spec = inspect_getfullargspec(func)

if spec.varargs:
raise CompileError("Lambdas with *args are not supported")
if spec.varkw:
raise CompileError("Lambdas with **kwargs are not supported")

args = [literal_column("$" + arg) for arg in spec.args]
text = f'({", ".join("$" + arg for arg in spec.args)}) -> ' f"{{ RETURN {self.process(func(*args), **kw)} ;}}"

return text

def _yson_convert_to(self, statement: str, target_type: sa.types.TypeEngine) -> str:
if isinstance(target_type, sa.Float):
# JSON.as_float() follows SQLAlchemy's generic FLOAT semantics.
# The rest of the 1.4 dialect retains its historical mapping of
# sa.Float to YDB Double.
type_name = "FLOAT"
else:
type_name = target_type.compile(self.dialect)

if isinstance(target_type, sa.Numeric) and not isinstance(target_type, sa.Float):
# Since Decimal is stored in JSON either as String or as Float
string_value = f"Yson::ConvertTo({statement}, Optional<String>, Yson::Options(true AS AutoConvert))"
return f"CAST({string_value} AS Optional<{type_name}>)"
return f"Yson::ConvertTo({statement}, Optional<{type_name}>)"

def visit_upsert(self, insert_stmt, **kw):
return self.visit_insert(insert_stmt, **kw).replace("INSERT", "UPSERT", 1)

Expand Down
Loading
Loading