Skip to content

Commit 50f029a

Browse files
authored
Merge pull request #37 Feature: Secondary indexes from LuckySting/feat/ydb-secondary-indicies
Thanks for the PR :)
2 parents 7926d85 + fe0c91a commit 50f029a

File tree

3 files changed

+235
-3
lines changed

3 files changed

+235
-3
lines changed

test/test_core.py

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -788,3 +788,179 @@ def test_insert_in_name_and_field(self, connection):
788788
row = connection.execute(sa.select(tb).where(tb.c.id == 2)).fetchone()
789789

790790
assert row == (2, "INSERT is my favourite operation")
791+
792+
793+
class TestSecondaryIndex(TestBase):
794+
__backend__ = True
795+
796+
def test_column_indexes(self, connection: sa.Connection, metadata: sa.MetaData):
797+
table = Table(
798+
"test_column_indexes/table",
799+
metadata,
800+
sa.Column("id", sa.Integer, primary_key=True),
801+
sa.Column("index_col1", sa.Integer, index=True),
802+
sa.Column("index_col2", sa.Integer, index=True),
803+
)
804+
table.create(connection)
805+
806+
table_desc: ydb.TableDescription = connection.connection.driver_connection.describe(table.name)
807+
indexes: list[ydb.TableIndex] = table_desc.indexes
808+
assert len(indexes) == 2
809+
indexes_map = {idx.name: idx for idx in indexes}
810+
811+
assert "ix_test_column_indexes_table_index_col1" in indexes_map
812+
index1 = indexes_map["ix_test_column_indexes_table_index_col1"]
813+
assert index1.index_columns == ["index_col1"]
814+
815+
assert "ix_test_column_indexes_table_index_col2" in indexes_map
816+
index1 = indexes_map["ix_test_column_indexes_table_index_col2"]
817+
assert index1.index_columns == ["index_col2"]
818+
819+
def test_async_index(self, connection: sa.Connection, metadata: sa.MetaData):
820+
table = Table(
821+
"test_async_index/table",
822+
metadata,
823+
sa.Column("id", sa.Integer, primary_key=True),
824+
sa.Column("index_col1", sa.Integer),
825+
sa.Column("index_col2", sa.Integer),
826+
sa.Index("test_async_index", "index_col1", "index_col2", ydb_async=True),
827+
)
828+
table.create(connection)
829+
830+
table_desc: ydb.TableDescription = connection.connection.driver_connection.describe(table.name)
831+
indexes: list[ydb.TableIndex] = table_desc.indexes
832+
assert len(indexes) == 1
833+
index = indexes[0]
834+
assert index.name == "test_async_index"
835+
assert set(index.index_columns) == {"index_col1", "index_col2"}
836+
# TODO: Check type after https://github.com/ydb-platform/ydb-python-sdk/issues/351
837+
838+
def test_cover_index(self, connection: sa.Connection, metadata: sa.MetaData):
839+
table = Table(
840+
"test_cover_index/table",
841+
metadata,
842+
sa.Column("id", sa.Integer, primary_key=True),
843+
sa.Column("index_col1", sa.Integer),
844+
sa.Column("index_col2", sa.Integer),
845+
sa.Index("test_cover_index", "index_col1", ydb_cover=["index_col2"]),
846+
)
847+
table.create(connection)
848+
849+
table_desc: ydb.TableDescription = connection.connection.driver_connection.describe(table.name)
850+
indexes: list[ydb.TableIndex] = table_desc.indexes
851+
assert len(indexes) == 1
852+
index = indexes[0]
853+
assert index.name == "test_cover_index"
854+
assert set(index.index_columns) == {"index_col1"}
855+
# TODO: Check covered columns after https://github.com/ydb-platform/ydb-python-sdk/issues/409
856+
857+
def test_indexes_reflection(self, connection: sa.Connection, metadata: sa.MetaData):
858+
table = Table(
859+
"test_indexes_reflection/table",
860+
metadata,
861+
sa.Column("id", sa.Integer, primary_key=True),
862+
sa.Column("index_col1", sa.Integer, index=True),
863+
sa.Column("index_col2", sa.Integer),
864+
sa.Index("test_index", "index_col1", "index_col2"),
865+
sa.Index("test_async_index", "index_col1", "index_col2", ydb_async=True),
866+
sa.Index("test_cover_index", "index_col1", ydb_cover=["index_col2"]),
867+
sa.Index("test_async_cover_index", "index_col1", ydb_async=True, ydb_cover=["index_col2"]),
868+
)
869+
table.create(connection)
870+
871+
indexes = sa.inspect(connection).get_indexes(table.name)
872+
assert len(indexes) == 5
873+
indexes_names = {idx["name"]: set(idx["column_names"]) for idx in indexes}
874+
875+
assert indexes_names == {
876+
"ix_test_indexes_reflection_table_index_col1": {"index_col1"},
877+
"test_index": {"index_col1", "index_col2"},
878+
"test_async_index": {"index_col1", "index_col2"},
879+
"test_cover_index": {"index_col1"},
880+
"test_async_cover_index": {"index_col1"},
881+
}
882+
883+
def test_index_simple_usage(self, connection: sa.Connection, metadata: sa.MetaData):
884+
persons = Table(
885+
"test_index_simple_usage/persons",
886+
metadata,
887+
sa.Column("id", sa.Integer(), primary_key=True),
888+
sa.Column("tax_number", sa.Integer()),
889+
sa.Column("full_name", sa.Unicode()),
890+
sa.Index("ix_tax_number_cover_full_name", "tax_number", ydb_cover=["full_name"]),
891+
)
892+
persons.create(connection)
893+
connection.execute(
894+
sa.insert(persons).values(
895+
[
896+
{"id": 1, "tax_number": 333333, "full_name": "John Connor"},
897+
{"id": 2, "tax_number": 444444, "full_name": "Sarah Connor"},
898+
]
899+
)
900+
)
901+
902+
# Because of this bug https://github.com/ydb-platform/ydb/issues/3510,
903+
# it is not possible to use full qualified name of columns with VIEW clause
904+
select_stmt = (
905+
sa.select(sa.column(persons.c.full_name.name))
906+
.select_from(persons)
907+
.with_hint(persons, "VIEW `ix_tax_number_cover_full_name`")
908+
.where(sa.column(persons.c.tax_number.name) == 444444)
909+
)
910+
911+
cursor = connection.execute(select_stmt)
912+
assert cursor.scalar_one() == "Sarah Connor"
913+
914+
def test_index_with_join_usage(self, connection: sa.Connection, metadata: sa.MetaData):
915+
persons = Table(
916+
"test_index_with_join_usage/persons",
917+
metadata,
918+
sa.Column("id", sa.Integer(), primary_key=True),
919+
sa.Column("tax_number", sa.Integer()),
920+
sa.Column("full_name", sa.Unicode()),
921+
sa.Index("ix_tax_number_cover_full_name", "tax_number", ydb_cover=["full_name"]),
922+
)
923+
persons.create(connection)
924+
connection.execute(
925+
sa.insert(persons).values(
926+
[
927+
{"id": 1, "tax_number": 333333, "full_name": "John Connor"},
928+
{"id": 2, "tax_number": 444444, "full_name": "Sarah Connor"},
929+
]
930+
)
931+
)
932+
person_status = Table(
933+
"test_index_with_join_usage/person_status",
934+
metadata,
935+
sa.Column("id", sa.Integer(), primary_key=True),
936+
sa.Column("status", sa.Unicode()),
937+
)
938+
person_status.create(connection)
939+
connection.execute(
940+
sa.insert(person_status).values(
941+
[
942+
{"id": 1, "status": "unknown"},
943+
{"id": 2, "status": "wanted"},
944+
]
945+
)
946+
)
947+
948+
# Because of this bug https://github.com/ydb-platform/ydb/issues/3510,
949+
# it is not possible to use full qualified name of columns with VIEW clause
950+
persons_indexed = (
951+
sa.select(
952+
sa.column(persons.c.id.name),
953+
sa.column(persons.c.full_name.name),
954+
sa.column(persons.c.tax_number.name),
955+
)
956+
.select_from(persons)
957+
.with_hint(persons, "VIEW `ix_tax_number_cover_full_name`")
958+
)
959+
select_stmt = (
960+
sa.select(persons_indexed.c.full_name, person_status.c.status)
961+
.select_from(person_status.join(persons_indexed, persons_indexed.c.id == person_status.c.id))
962+
.where(persons_indexed.c.tax_number == 444444)
963+
)
964+
965+
cursor = connection.execute(select_stmt)
966+
assert cursor.one() == ("Sarah Connor", "wanted")

ydb_sqlalchemy/sqlalchemy/__init__.py

Lines changed: 58 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,9 @@ def __init__(self, dialect):
6262
final_quote="`",
6363
)
6464

65+
def format_index(self, index: sa.Index) -> str:
66+
return super().format_index(index).replace("/", "_")
67+
6568

6669
class YqlTypeCompiler(StrSQLTypeCompiler):
6770
def visit_JSON(self, type_: Union[sa.JSON, types.YqlJSON], **kw):
@@ -232,6 +235,9 @@ def __init__(self, name, params, *args, **kwargs):
232235
class YqlCompiler(StrSQLCompiler):
233236
compound_keywords = COMPOUND_KEYWORDS
234237

238+
def get_from_hint_text(self, table, text):
239+
return text
240+
235241
def render_bind_cast(self, type_, dbapi_type, sqltext):
236242
pass
237243

@@ -446,6 +452,34 @@ def visit_upsert(self, insert_stmt, visited_bindparam=None, **kw):
446452

447453

448454
class YqlDDLCompiler(DDLCompiler):
455+
def visit_create_index(self, create, include_schema=False, include_table_schema=True, **kw):
456+
index: sa.Index = create.element
457+
ydb_opts = index.dialect_options.get("ydb", {})
458+
459+
self._verify_index_table(index)
460+
461+
if index.name is None:
462+
raise CompileError("ADD INDEX requires that the index has a name")
463+
464+
table_name = self.preparer.format_table(index.table)
465+
index_name = self._prepared_index_name(index)
466+
467+
text = f"ALTER TABLE {table_name} ADD INDEX {index_name} GLOBAL"
468+
469+
text += " SYNC" if not ydb_opts.get("async", False) else " ASYNC"
470+
471+
columns = {self.preparer.format_column(col) for col in index.columns.values()}
472+
cover_columns = {
473+
col if isinstance(col, str) else self.preparer.format_column(col) for col in ydb_opts.get("cover", [])
474+
}
475+
476+
text += " ON (" + ", ".join(columns) + ")"
477+
478+
if cover_columns:
479+
text += " COVER (" + ", ".join(cover_columns) + ")"
480+
481+
return text
482+
449483
def post_create_table(self, table: sa.Table) -> str:
450484
ydb_opts = table.dialect_options["ydb"]
451485
with_clause_list = self._render_table_partitioning_settings(ydb_opts)
@@ -578,6 +612,13 @@ class YqlDialect(StrCompileDialect):
578612
"partition_at_keys": None,
579613
},
580614
),
615+
(
616+
sa.schema.Index,
617+
{
618+
"async": False,
619+
"cover": [],
620+
},
621+
),
581622
]
582623

583624
@classmethod
@@ -599,7 +640,7 @@ def __init__(
599640
# no need in declare in yql statement here since ydb 24-1
600641
self._add_declare_for_yql_stmt_vars = _add_declare_for_yql_stmt_vars
601642

602-
def _describe_table(self, connection, table_name, schema=None):
643+
def _describe_table(self, connection, table_name, schema=None) -> ydb.TableDescription:
603644
if schema is not None:
604645
raise dbapi.NotSupportedError("unsupported on non empty schema")
605646

@@ -655,8 +696,22 @@ def get_foreign_keys(self, connection, table_name, schema=None, **kwargs):
655696

656697
@reflection.cache
657698
def get_indexes(self, connection, table_name, schema=None, **kwargs):
658-
# TODO: implement me
659-
return []
699+
table = self._describe_table(connection, table_name, schema)
700+
indexes: list[ydb.TableIndex] = table.indexes
701+
sa_indexes: list[sa.engine.interfaces.ReflectedIndex] = []
702+
for index in indexes:
703+
sa_indexes.append(
704+
sa.engine.interfaces.ReflectedIndex(
705+
name=index.name,
706+
column_names=index.index_columns,
707+
unique=False,
708+
dialect_options={
709+
"ydb_async": False, # TODO After https://github.com/ydb-platform/ydb-python-sdk/issues/351
710+
"ydb_cover": [], # TODO After https://github.com/ydb-platform/ydb-python-sdk/issues/409
711+
},
712+
)
713+
)
714+
return sa_indexes
660715

661716
def set_isolation_level(self, dbapi_connection: dbapi.Connection, level: str) -> None:
662717
dbapi_connection.set_isolation_level(level)

ydb_sqlalchemy/sqlalchemy/requirements.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ def temporary_views(self):
4646

4747
@property
4848
def index_reflection(self):
49+
# Reflection supported with limits
4950
return exclusions.closed()
5051

5152
@property

0 commit comments

Comments
 (0)