From 6ed569729bf5cc3b36ef747b343ee6cc8ae63296 Mon Sep 17 00:00:00 2001 From: Nathaniel Ramm Date: Thu, 2 Jul 2026 12:24:51 +1000 Subject: [PATCH 01/11] feat(ibis): add DropScope + IndexCapability model + index_caps field --- .../backends/ibis/dialects/_registry.py | 22 +++++++++++ .../backends/ibis/test_index_capability.py | 37 +++++++++++++++++++ 2 files changed, 59 insertions(+) create mode 100644 tests/test_unit/backends/ibis/test_index_capability.py diff --git a/src/mountainash_data/backends/ibis/dialects/_registry.py b/src/mountainash_data/backends/ibis/dialects/_registry.py index bca0004..71ec394 100644 --- a/src/mountainash_data/backends/ibis/dialects/_registry.py +++ b/src/mountainash_data/backends/ibis/dialects/_registry.py @@ -28,6 +28,26 @@ class UpsertStyle(str, enum.Enum): ON_DUPLICATE_KEY = "on_duplicate_key" +class DropScope(str, enum.Enum): + SCHEMA_GLOBAL = "schema_global" # DROP INDEX name + TABLE_SCOPED = "table_scoped" # DROP INDEX name ON tbl + + +@dataclass(frozen=True) +class IndexCapability: + """Per-dialect conventional-B-tree index capability (spec §3). + + None on DialectSpec.index_caps means the dialect has no conventional + secondary index -> create/drop raise NotImplementedError. + """ + + drop_scope: DropScope + partial: bool # supports a WHERE filter (partial/filtered index) + native_if_not_exists: bool # engine has CREATE INDEX IF NOT EXISTS + native_if_exists: bool # engine has DROP INDEX IF EXISTS + index_types: frozenset[str] # valid USING values; empty = no USING clause + + # Capability hook signatures GetIndexExistsSql = t.Callable[[str, str, t.Optional[str]], str] # (index_name, table_name, database) -> SQL GetListIndexesSql = t.Callable[[str, t.Optional[str]], str] # (table_name, database) -> SQL @@ -52,6 +72,8 @@ class DialectSpec: upsert_hook: t.Optional[UpsertHook] = None # None = upsert not supported (no hook + no style -> NotImplementedError). upsert_style: t.Optional[UpsertStyle] = None + index_caps: t.Optional[IndexCapability] = None + # None = no conventional index support -> NotImplementedError. create_index_hook: t.Optional[CreateIndexHook] = None drop_index_hook: t.Optional[DropIndexHook] = None rename_table_hook: t.Optional[RenameTableHook] = None diff --git a/tests/test_unit/backends/ibis/test_index_capability.py b/tests/test_unit/backends/ibis/test_index_capability.py new file mode 100644 index 0000000..f78ee82 --- /dev/null +++ b/tests/test_unit/backends/ibis/test_index_capability.py @@ -0,0 +1,37 @@ +"""IndexCapability descriptor + DropScope enum (registry capability model).""" + +import dataclasses + +import pytest + +from mountainash_data.backends.ibis.dialects._registry import ( + DialectSpec, + DropScope, + IndexCapability, +) + + +def test_dropscope_members(): + assert DropScope.SCHEMA_GLOBAL.value == "schema_global" + assert DropScope.TABLE_SCOPED.value == "table_scoped" + + +def test_index_capability_is_frozen(): + caps = IndexCapability( + drop_scope=DropScope.SCHEMA_GLOBAL, + partial=True, + native_if_not_exists=True, + native_if_exists=True, + index_types=frozenset({"btree"}), + ) + with pytest.raises(dataclasses.FrozenInstanceError): + caps.partial = False # type: ignore[misc] + + +def test_dialectspec_index_caps_defaults_none(): + spec = DialectSpec( + ibis_backend_name="x", + connection_mode="kwargs", + connection_string_scheme="x://", + ) + assert spec.index_caps is None From 63cf97791f93aec631cf662f6b0b7158ee050db8 Mon Sep 17 00:00:00 2001 From: Nathaniel Ramm Date: Thu, 2 Jul 2026 12:27:46 +1000 Subject: [PATCH 02/11] feat(ibis): assign index_caps to sqlite/duckdb/motherduck (introspection-ready) --- .../backends/ibis/dialects/_registry.py | 15 +++++ .../backends/ibis/test_index_capability.py | 60 +++++++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/src/mountainash_data/backends/ibis/dialects/_registry.py b/src/mountainash_data/backends/ibis/dialects/_registry.py index 71ec394..7a74401 100644 --- a/src/mountainash_data/backends/ibis/dialects/_registry.py +++ b/src/mountainash_data/backends/ibis/dialects/_registry.py @@ -687,6 +687,11 @@ def _build_pyspark_connection(**config: t.Any) -> t.Any: upsert_style=UpsertStyle.ON_CONFLICT, create_index_hook=duckdb_family_create_index, drop_index_hook=duckdb_family_drop_index, + index_caps=IndexCapability( + drop_scope=DropScope.SCHEMA_GLOBAL, partial=True, + native_if_not_exists=True, native_if_exists=True, + index_types=frozenset(), + ), ), "duckdb": DialectSpec( ibis_backend_name="duckdb", @@ -698,6 +703,11 @@ def _build_pyspark_connection(**config: t.Any) -> t.Any: upsert_style=UpsertStyle.ON_CONFLICT, create_index_hook=duckdb_family_create_index, drop_index_hook=duckdb_family_drop_index, + index_caps=IndexCapability( + drop_scope=DropScope.SCHEMA_GLOBAL, partial=False, + native_if_not_exists=True, native_if_exists=True, + index_types=frozenset(), + ), ), "motherduck": DialectSpec( ibis_backend_name="duckdb", @@ -709,6 +719,11 @@ def _build_pyspark_connection(**config: t.Any) -> t.Any: upsert_style=UpsertStyle.ON_CONFLICT, create_index_hook=duckdb_family_create_index, drop_index_hook=duckdb_family_drop_index, + index_caps=IndexCapability( + drop_scope=DropScope.SCHEMA_GLOBAL, partial=False, + native_if_not_exists=True, native_if_exists=True, + index_types=frozenset(), + ), ), "postgres": DialectSpec( ibis_backend_name="postgres", diff --git a/tests/test_unit/backends/ibis/test_index_capability.py b/tests/test_unit/backends/ibis/test_index_capability.py index f78ee82..e99c8da 100644 --- a/tests/test_unit/backends/ibis/test_index_capability.py +++ b/tests/test_unit/backends/ibis/test_index_capability.py @@ -35,3 +35,63 @@ def test_dialectspec_index_caps_defaults_none(): connection_string_scheme="x://", ) assert spec.index_caps is None + + +from mountainash_data.backends.ibis.dialects._registry import DIALECTS + +# Verified against official vendor docs 2026-06-30 (spec §4). frozenset of USING types. +# (drop_scope, partial, native_if_not_exists, native_if_exists, index_types) +_EXPECTED = { + "sqlite": (DropScope.SCHEMA_GLOBAL, True, True, True, frozenset()), + "duckdb": (DropScope.SCHEMA_GLOBAL, False, True, True, frozenset()), + "motherduck": (DropScope.SCHEMA_GLOBAL, False, True, True, frozenset()), + "postgres": (DropScope.SCHEMA_GLOBAL, True, True, True, + frozenset({"btree", "hash", "gist", "gin", "brin", "spgist"})), + "mysql": (DropScope.TABLE_SCOPED, False, False, False, frozenset({"btree"})), + "singlestoredb": (DropScope.TABLE_SCOPED, False, False, False, frozenset({"btree", "hash"})), + "mssql": (DropScope.TABLE_SCOPED, True, False, True, frozenset()), + "oracle": (DropScope.SCHEMA_GLOBAL, False, False, False, frozenset()), +} + +# Dialects that carry index_caps after THIS task. Task 5 appends the other 5. +_ASSIGNED_NOW = ["sqlite", "duckdb", "motherduck"] + +_UNSUPPORTED = { + "snowflake", "bigquery", "redshift", "trino", "clickhouse", "databricks", + "exasol", "impala", "materialize", "risingwave", "druid", "pyspark", +} + + +@pytest.mark.parametrize("name", _ASSIGNED_NOW) +def test_index_caps_matrix(name): + caps = DIALECTS[name].index_caps + assert caps is not None, f"{name} must have index_caps" + drop_scope, partial, ine, ie, types = _EXPECTED[name] + assert caps.drop_scope is drop_scope + assert caps.partial is partial + assert caps.native_if_not_exists is ine + assert caps.native_if_exists is ie + assert caps.index_types == types + + +@pytest.mark.parametrize("name", sorted(_UNSUPPORTED)) +def test_unsupported_dialects_have_no_index_caps(name): + assert DIALECTS[name].index_caps is None + + +@pytest.mark.parametrize("name", _ASSIGNED_NOW) +def test_invariant_caps_implies_exists_sql(name): + """Spec §3 invariant: a dialect with index_caps must also introspect indexes.""" + spec = DIALECTS[name] + assert spec.index_caps is not None + assert spec.get_index_exists_sql is not None + + +def test_no_dialect_violates_invariant(): + """Stronger guard: NO dialect may have index_caps without exists_sql — true at + every commit, including this one (the other 5 caps are not assigned yet).""" + for name, spec in DIALECTS.items(): + if spec.index_caps is not None: + assert spec.get_index_exists_sql is not None, ( + f"{name}: index_caps set but get_index_exists_sql missing" + ) From 7dbb1fb57e794243db41257686a96980d6ca7c4b Mon Sep 17 00:00:00 2001 From: Nathaniel Ramm Date: Thu, 2 Jul 2026 12:31:07 +1000 Subject: [PATCH 03/11] feat(ibis): add compile_index_predicate single-relation WHERE compiler --- src/mountainash_data/backends/ibis/_render.py | 55 +++++++++++++++++++ .../backends/ibis/test_index_render.py | 38 +++++++++++++ 2 files changed, 93 insertions(+) create mode 100644 tests/test_unit/backends/ibis/test_index_render.py diff --git a/src/mountainash_data/backends/ibis/_render.py b/src/mountainash_data/backends/ibis/_render.py index d1444f7..8088056 100644 --- a/src/mountainash_data/backends/ibis/_render.py +++ b/src/mountainash_data/backends/ibis/_render.py @@ -209,3 +209,58 @@ def _remap(n: exp.Expression) -> exp.Expression: return n return on.transform(_remap) + + +# --------------------------------------------------------------------------- +# Index-predicate compiler (§5.2) +# --------------------------------------------------------------------------- + +INDEX_SENTINEL = "__ma_index_tbl__" + +IndexPredicate = t.Callable[[ir.Table], ir.BooleanValue] + + +def compile_index_predicate( + ibis_conn: t.Any, + schema: t.Any, + table_name: str, + predicate: IndexPredicate, +) -> str: + """Compile a single-table ``(table) -> bool`` predicate to an UNQUALIFIED + boolean SQL string for the connection's dialect (partial-index WHERE). + + Mechanism (spec §5.2): bind one sentinel-named ibis table at `schema`, + filter it by the predicate, compile to sqlglot, extract the WHERE, then + strip every column's table/db/catalog qualifier at the AST level (NOT by + string replacement). The predicate may reference any column of the table, + not only the indexed columns, so the full `schema` is bound. + + Raises: + ValueError: if `table_name` collides with the reserved sentinel, or the + predicate contains a forbidden op (aggregation/window/subquery). + """ + if table_name == INDEX_SENTINEL: + raise ValueError( + f"target table name {table_name!r} collides with a reserved sentinel." + ) + tbl = ibis.table(schema, name=INDEX_SENTINEL) + pred = predicate(tbl) + validate_predicate(pred) + + filtered = tbl.filter(pred) + ast = ibis_conn.compiler.to_sqlglot(filtered) + ast = ast if isinstance(ast, exp.Expression) else ast[0] + + where = next(ast.find_all(exp.Where), None) + if where is None or where.this is None: + raise ValueError("could not extract WHERE predicate from compiled AST") + cond = where.this.copy() + + def _strip(n: exp.Expression) -> exp.Expression: + if isinstance(n, exp.Column): + n.set("table", None) + n.set("db", None) + n.set("catalog", None) + return n + + return cond.transform(_strip).sql(dialect=dialect_of(ibis_conn)) diff --git a/tests/test_unit/backends/ibis/test_index_render.py b/tests/test_unit/backends/ibis/test_index_render.py new file mode 100644 index 0000000..55ccac8 --- /dev/null +++ b/tests/test_unit/backends/ibis/test_index_render.py @@ -0,0 +1,38 @@ +"""Index render primitives: predicate compiler + pure builders + introspection SQL.""" + +import ibis +import pytest + +from mountainash_data.backends.ibis._render import compile_index_predicate + +_SCHEMA = ibis.schema({"id": "int64", "active": "boolean", "ver": "int64"}) + + +def _pred_sql(predicate, *, table_name="t"): + con = ibis.duckdb.connect() + return compile_index_predicate(con, _SCHEMA, table_name, predicate) + + +class TestCompileIndexPredicate: + def test_renders_unqualified_columns(self): + sql = _pred_sql(lambda t: t.active == True) # noqa: E712 + # the column must be UNqualified (no table/alias prefix) + assert '"active"' in sql + assert "." not in sql.split('"active"')[0][-3:] # no `x.` before "active" + + def test_comparison_predicate(self): + sql = _pred_sql(lambda t: t.ver > 5) + assert '"ver"' in sql and "5" in sql + + def test_predicate_may_reference_non_indexed_column(self): + # binding the full schema (not just indexed cols) must allow this + sql = _pred_sql(lambda t: t.active) + assert '"active"' in sql + + def test_rejects_sentinel_table_name(self): + with pytest.raises(ValueError, match="sentinel"): + _pred_sql(lambda t: t.id > 0, table_name="__ma_index_tbl__") + + def test_rejects_aggregate(self): + with pytest.raises(ValueError, match="aggregat|window|scalar|subquer|row predicate"): + _pred_sql(lambda t: t.id.sum() > 0) From a3556b657fcf18fc00037920aea8511e6d8c8881 Mon Sep 17 00:00:00 2001 From: Nathaniel Ramm Date: Thu, 2 Jul 2026 12:33:51 +1000 Subject: [PATCH 04/11] test(ibis): assert unqualified output in index-predicate tests --- src/mountainash_data/backends/ibis/_render.py | 4 +++- tests/test_unit/backends/ibis/test_index_render.py | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/mountainash_data/backends/ibis/_render.py b/src/mountainash_data/backends/ibis/_render.py index 8088056..9dc194a 100644 --- a/src/mountainash_data/backends/ibis/_render.py +++ b/src/mountainash_data/backends/ibis/_render.py @@ -233,7 +233,9 @@ def compile_index_predicate( filter it by the predicate, compile to sqlglot, extract the WHERE, then strip every column's table/db/catalog qualifier at the AST level (NOT by string replacement). The predicate may reference any column of the table, - not only the indexed columns, so the full `schema` is bound. + not only the indexed columns, so the full `schema` is bound. The + `table_name` parameter is validated only (sentinel-collision check) and + does NOT appear in the returned SQL. Raises: ValueError: if `table_name` collides with the reserved sentinel, or the diff --git a/tests/test_unit/backends/ibis/test_index_render.py b/tests/test_unit/backends/ibis/test_index_render.py index 55ccac8..9b80291 100644 --- a/tests/test_unit/backends/ibis/test_index_render.py +++ b/tests/test_unit/backends/ibis/test_index_render.py @@ -23,11 +23,13 @@ def test_renders_unqualified_columns(self): def test_comparison_predicate(self): sql = _pred_sql(lambda t: t.ver > 5) assert '"ver"' in sql and "5" in sql + assert "." not in sql def test_predicate_may_reference_non_indexed_column(self): # binding the full schema (not just indexed cols) must allow this sql = _pred_sql(lambda t: t.active) assert '"active"' in sql + assert "." not in sql def test_rejects_sentinel_table_name(self): with pytest.raises(ValueError, match="sentinel"): From 81e800f27e2300464d390b1feab32b3f66344e18 Mon Sep 17 00:00:00 2001 From: Nathaniel Ramm Date: Thu, 2 Jul 2026 12:36:45 +1000 Subject: [PATCH 05/11] feat(ibis): pure CREATE/DROP INDEX builders with dialect USING placement --- src/mountainash_data/backends/ibis/_index.py | 83 +++++++++++++++++++ .../backends/ibis/test_index_render.py | 69 +++++++++++++++ 2 files changed, 152 insertions(+) create mode 100644 src/mountainash_data/backends/ibis/_index.py diff --git a/src/mountainash_data/backends/ibis/_index.py b/src/mountainash_data/backends/ibis/_index.py new file mode 100644 index 0000000..d8eab81 --- /dev/null +++ b/src/mountainash_data/backends/ibis/_index.py @@ -0,0 +1,83 @@ +"""Generic-default index DDL: pure builders + dispatchers (spec §5). + +Pure builders take pre-computed, already-validated parts so registry golden +tests render every dialect without a live connection. +""" + +from __future__ import annotations + +import typing as t + +from mountainash_data.backends.ibis._render import quote_identifier +from mountainash_data.backends.ibis.dialects._registry import DropScope + +# USING position differs across dialects (verified against official docs): +# - Postgres: CREATE INDEX i ON tbl USING gin (cols) -> after ON, before columns +# - MySQL/MariaDB: CREATE INDEX i USING btree ON tbl (cols) -> after index name, before ON +# - SingleStore: CREATE INDEX i ON tbl (cols) USING hash -> after columns (the default) +# sqlite/duckdb/motherduck/mssql/oracle have empty index_types -> no USING emitted. +_USING_BEFORE_ON: frozenset[str] = frozenset({"mysql"}) +_USING_BEFORE_COLUMNS: frozenset[str] = frozenset({"postgres"}) + + +def build_create_index_sql( + *, + dialect: t.Any, + target: str, + index_name: str, + cols: list[str], + unique: bool, + index_type: t.Optional[str], + guard: str, + where_sql: t.Optional[str], +) -> str: + """Render a CREATE INDEX statement from pre-validated parts. + + Args: + dialect: sqlglot dialect string (e.g. ``dialect_of(ibis_conn)``). + target: already-qualified, already-quoted table reference. + index_name: unquoted index name. + cols: unquoted column names. + unique: emit CREATE UNIQUE INDEX. + index_type: USING , or None for no USING clause. + guard: ``"IF NOT EXISTS "`` or ``""`` (emulation supplies idempotency). + where_sql: rendered partial-index WHERE body, or None. + """ + unique_sql = "UNIQUE " if unique else "" + cols_sql = ", ".join(quote_identifier(c, dialect) for c in cols) + name_sql = quote_identifier(index_name, dialect) + where = f" WHERE {where_sql}" if where_sql else "" + name_part = f"{guard}{name_sql}" + d = str(dialect) + using = f"USING {index_type}" if index_type else None + + if using and d in _USING_BEFORE_ON: + # MySQL/MariaDB: USING sits between the index name and ON. + name_part = f"{name_part} {using}" + tail = f"ON {target} ({cols_sql})" + elif using and d in _USING_BEFORE_COLUMNS: + # Postgres: USING sits after ON, before the column list. + tail = f"ON {target} {using} ({cols_sql})" + elif using: + # SingleStore (and the general default): USING after the column list. + tail = f"ON {target} ({cols_sql}) {using}" + else: + tail = f"ON {target} ({cols_sql})" + + return f"CREATE {unique_sql}INDEX {name_part} {tail}{where}" + + +def build_drop_index_sql( + *, + dialect: t.Any, + drop_scope: DropScope, + index_name: str, + target: t.Optional[str], + guard: str, +) -> str: + """Render a DROP INDEX statement. `target` is required (already quoted) when + `drop_scope` is TABLE_SCOPED.""" + name_sql = quote_identifier(index_name, dialect) + if drop_scope is DropScope.TABLE_SCOPED: + return f"DROP INDEX {guard}{name_sql} ON {target}" + return f"DROP INDEX {guard}{name_sql}" diff --git a/tests/test_unit/backends/ibis/test_index_render.py b/tests/test_unit/backends/ibis/test_index_render.py index 9b80291..3e39612 100644 --- a/tests/test_unit/backends/ibis/test_index_render.py +++ b/tests/test_unit/backends/ibis/test_index_render.py @@ -38,3 +38,72 @@ def test_rejects_sentinel_table_name(self): def test_rejects_aggregate(self): with pytest.raises(ValueError, match="aggregat|window|scalar|subquer|row predicate"): _pred_sql(lambda t: t.id.sum() > 0) + + +from mountainash_data.backends.ibis._index import ( # noqa: E402 + build_create_index_sql, + build_drop_index_sql, +) +from mountainash_data.backends.ibis.dialects._registry import DropScope # noqa: E402 + + +class TestBuildCreateIndexSql: + def test_basic(self): + sql = build_create_index_sql( + dialect="duckdb", target='"t"', index_name="idx_t_id", + cols=["id"], unique=False, index_type=None, guard="", where_sql=None, + ) + assert sql == 'CREATE INDEX "idx_t_id" ON "t" ("id")' + + def test_unique_and_guard(self): + sql = build_create_index_sql( + dialect="duckdb", target='"t"', index_name="u", cols=["a", "b"], + unique=True, index_type=None, guard="IF NOT EXISTS ", where_sql=None, + ) + assert sql == 'CREATE UNIQUE INDEX IF NOT EXISTS "u" ON "t" ("a", "b")' + + def test_partial_where(self): + sql = build_create_index_sql( + dialect="duckdb", target='"t"', index_name="p", cols=["id"], + unique=False, index_type=None, guard="", where_sql='"active"', + ) + assert sql.endswith('("id") WHERE "active"') + + def test_using_before_columns_postgres(self): + sql = build_create_index_sql( + dialect="postgres", target='"t"', index_name="g", cols=["doc"], + unique=False, index_type="gin", guard="", where_sql=None, + ) + assert sql == 'CREATE INDEX "g" ON "t" USING gin ("doc")' + + def test_using_before_on_mysql(self): + # MySQL/MariaDB place USING between the index name and ON (verified: + # dev.mysql.com 8.4 CREATE INDEX grammar `index_name [index_type] ON`). + sql = build_create_index_sql( + dialect="mysql", target="`t`", index_name="i", cols=["id"], + unique=False, index_type="btree", guard="", where_sql=None, + ) + assert sql == "CREATE INDEX `i` USING btree ON `t` (`id`)" + + def test_using_after_columns_singlestore(self): + sql = build_create_index_sql( + dialect="singlestore", target="`t`", index_name="i", cols=["id"], + unique=False, index_type="hash", guard="", where_sql=None, + ) + assert sql == "CREATE INDEX `i` ON `t` (`id`) USING hash" + + +class TestBuildDropIndexSql: + def test_schema_global(self): + sql = build_drop_index_sql( + dialect="duckdb", drop_scope=DropScope.SCHEMA_GLOBAL, + index_name="idx", target=None, guard="IF EXISTS ", + ) + assert sql == 'DROP INDEX IF EXISTS "idx"' + + def test_table_scoped(self): + sql = build_drop_index_sql( + dialect="mysql", drop_scope=DropScope.TABLE_SCOPED, + index_name="idx", target="`t`", guard="", + ) + assert sql == "DROP INDEX `idx` ON `t`" From 2ca2d094f46028d0fb181bd882b658ef831fec77 Mon Sep 17 00:00:00 2001 From: Nathaniel Ramm Date: Thu, 2 Jul 2026 12:42:51 +1000 Subject: [PATCH 06/11] feat(ibis): escape+harden index introspection SQL + assign caps for pg/mysql/mssql/oracle/singlestore --- .../backends/ibis/dialects/_registry.py | 35 +++++ .../backends/ibis/operations.py | 129 +++++++++++++++--- .../backends/ibis/test_index_capability.py | 8 +- .../backends/ibis/test_index_render.py | 65 +++++++++ 4 files changed, 215 insertions(+), 22 deletions(-) diff --git a/src/mountainash_data/backends/ibis/dialects/_registry.py b/src/mountainash_data/backends/ibis/dialects/_registry.py index 7a74401..9d74397 100644 --- a/src/mountainash_data/backends/ibis/dialects/_registry.py +++ b/src/mountainash_data/backends/ibis/dialects/_registry.py @@ -671,6 +671,11 @@ def _build_pyspark_connection(**config: t.Any) -> t.Any: sqlite_get_list_indexes_sql, motherduck_get_index_exists_sql, motherduck_get_list_indexes_sql, + postgres_get_index_exists_sql, + mysql_get_index_exists_sql, + mssql_get_index_exists_sql, + oracle_get_index_exists_sql, + singlestore_get_index_exists_sql, duckdb_family_create_index, duckdb_family_drop_index, ) @@ -731,6 +736,12 @@ def _build_pyspark_connection(**config: t.Any) -> t.Any: connection_string_scheme="postgres://", connection_builder=_build_postgres_connection, upsert_style=UpsertStyle.ON_CONFLICT, + get_index_exists_sql=postgres_get_index_exists_sql, + index_caps=IndexCapability( + drop_scope=DropScope.SCHEMA_GLOBAL, partial=True, + native_if_not_exists=True, native_if_exists=True, + index_types=frozenset({"btree", "hash", "gist", "gin", "brin", "spgist"}), + ), ), "mysql": DialectSpec( ibis_backend_name="mysql", @@ -738,6 +749,12 @@ def _build_pyspark_connection(**config: t.Any) -> t.Any: connection_string_scheme="mysql://", connection_builder=_build_mysql_connection, upsert_style=UpsertStyle.ON_DUPLICATE_KEY, + get_index_exists_sql=mysql_get_index_exists_sql, + index_caps=IndexCapability( + drop_scope=DropScope.TABLE_SCOPED, partial=False, + native_if_not_exists=False, native_if_exists=False, + index_types=frozenset({"btree"}), + ), ), "mssql": DialectSpec( ibis_backend_name="mssql", @@ -745,6 +762,12 @@ def _build_pyspark_connection(**config: t.Any) -> t.Any: connection_string_scheme="mssql://", connection_builder=_build_mssql_connection, upsert_style=UpsertStyle.MERGE, + get_index_exists_sql=mssql_get_index_exists_sql, + index_caps=IndexCapability( + drop_scope=DropScope.TABLE_SCOPED, partial=True, + native_if_not_exists=False, native_if_exists=True, + index_types=frozenset(), + ), ), "oracle": DialectSpec( ibis_backend_name="oracle", @@ -752,6 +775,12 @@ def _build_pyspark_connection(**config: t.Any) -> t.Any: connection_string_scheme="oracle://", connection_builder=_build_oracle_connection, upsert_style=UpsertStyle.MERGE, + get_index_exists_sql=oracle_get_index_exists_sql, + index_caps=IndexCapability( + drop_scope=DropScope.SCHEMA_GLOBAL, partial=False, + native_if_not_exists=False, native_if_exists=False, + index_types=frozenset(), + ), ), "snowflake": DialectSpec( ibis_backend_name="snowflake", @@ -800,6 +829,12 @@ def _build_pyspark_connection(**config: t.Any) -> t.Any: connection_string_scheme="singlestoredb://", connection_builder=_build_singlestoredb_connection, upsert_style=UpsertStyle.ON_DUPLICATE_KEY, + get_index_exists_sql=singlestore_get_index_exists_sql, + index_caps=IndexCapability( + drop_scope=DropScope.TABLE_SCOPED, partial=False, + native_if_not_exists=False, native_if_exists=False, + index_types=frozenset({"btree", "hash"}), + ), ), "exasol": DialectSpec( ibis_backend_name="exasol", diff --git a/src/mountainash_data/backends/ibis/operations.py b/src/mountainash_data/backends/ibis/operations.py index b21319e..e89a098 100644 --- a/src/mountainash_data/backends/ibis/operations.py +++ b/src/mountainash_data/backends/ibis/operations.py @@ -159,6 +159,13 @@ def _validate_simple_identifier(value: str, *, kind: str) -> None: ) +def _sql_literal(value: str) -> str: + """Render `value` as an escaped SQL string literal (defense-in-depth for the + catalog-introspection queries; identifiers are also allowlist-validated by + the generic dispatcher before reaching here).""" + return exp.Literal.string(value).sql() + + def build_rename_sql(old_name: str, new_name: str, *, dialect: t.Any) -> str: """Pure builder: render a portable rename for an explicit sqlglot dialect. @@ -238,11 +245,11 @@ def duckdb_get_index_exists_sql( database: str | None ) -> str: """DuckDB uses duckdb_indexes() system function.""" - where_clauses = [f"index_name = '{index_name}'"] + where_clauses = [f"index_name = {_sql_literal(index_name)}"] if table_name: - where_clauses.append(f"table_name = '{table_name}'") + where_clauses.append(f"table_name = {_sql_literal(table_name)}") if database: - where_clauses.append(f"database_name = '{database}'") + where_clauses.append(f"database_name = {_sql_literal(database)}") where_sql = " AND ".join(where_clauses) return f"SELECT COUNT(*) as count FROM duckdb_indexes() WHERE {where_sql}" @@ -275,18 +282,13 @@ def sqlite_get_index_exists_sql( table_name: str | None, database: str | None ) -> str: - """SQLite uses sqlite_master system table. - Note: database parameter is not used as SQLite doesn't support cross-database queries. - """ - where_clauses = [ - "type = 'index'", - f"name = '{index_name}'" - ] + """SQLite uses the sqlite_master system table. `database` is unused (no + cross-database queries).""" + where_clauses = ["type = 'index'", f"name = {_sql_literal(index_name)}"] if table_name: - where_clauses.append(f"tbl_name = '{table_name}'") - + where_clauses.append(f"tbl_name = {_sql_literal(table_name)}") where_sql = " AND ".join(where_clauses) - return f"SELECT COUNT(*) as count FROM sqlite_master WHERE {where_sql}" + return f"SELECT COUNT(*) AS count FROM sqlite_master WHERE {where_sql}" def sqlite_get_list_indexes_sql( @@ -315,11 +317,11 @@ def motherduck_get_index_exists_sql( database: str | None ) -> str: """MotherDuck uses DuckDB's duckdb_indexes() system function.""" - where_clauses = [f"index_name = '{index_name}'"] + where_clauses = [f"index_name = {_sql_literal(index_name)}"] if table_name: - where_clauses.append(f"table_name = '{table_name}'") + where_clauses.append(f"table_name = {_sql_literal(table_name)}") if database: - where_clauses.append(f"database_name = '{database}'") + where_clauses.append(f"database_name = {_sql_literal(database)}") where_sql = " AND ".join(where_clauses) return f"SELECT COUNT(*) as count FROM duckdb_indexes() WHERE {where_sql}" @@ -345,6 +347,101 @@ def motherduck_get_list_indexes_sql( """ +# --- PostgreSQL --- + +def postgres_get_index_exists_sql( + index_name: str, table_name: str | None, database: str | None +) -> str: + """PostgreSQL pg_indexes catalog view. `database` maps to schemaname.""" + where = [f"indexname = {_sql_literal(index_name)}"] + if table_name: + where.append(f"tablename = {_sql_literal(table_name)}") + if database: + where.append(f"schemaname = {_sql_literal(database)}") + return f"SELECT COUNT(*) AS count FROM pg_indexes WHERE {' AND '.join(where)}" + + +# --- MySQL / MariaDB --- + +def mysql_get_index_exists_sql( + index_name: str, table_name: str | None, database: str | None +) -> str: + """information_schema.STATISTICS (table-scoped). Defaults schema to the + current database when `database` is omitted.""" + where = [f"INDEX_NAME = {_sql_literal(index_name)}"] + if table_name: + where.append(f"TABLE_NAME = {_sql_literal(table_name)}") + schema_pred = ( + f"TABLE_SCHEMA = {_sql_literal(database)}" if database else "TABLE_SCHEMA = DATABASE()" + ) + where.append(schema_pred) + return ( + "SELECT COUNT(*) AS count FROM information_schema.STATISTICS " + f"WHERE {' AND '.join(where)}" + ) + + +# --- SQL Server --- + +def mssql_get_index_exists_sql( + index_name: str, table_name: str | None, database: str | None +) -> str: + """sys.indexes joined to the table via OBJECT_ID (table-scoped). + + NOTE on the `database` parameter: across this package `database` denotes the + immediate NAMESPACE qualifier, which SQL Server interprets as the *schema* in + a two-part name. The generic CREATE renders ``"".""`` (a + schema.object reference to SQL Server), so OBJECT_ID('.
') + targets the same object — consistent, not conflated. Cross-database + (three-part) index DDL is out of scope for the generic path. + """ + obj = table_name if table_name else "" + if database and table_name: + obj = f"{database}.{table_name}" + return ( + "SELECT COUNT(*) AS count FROM sys.indexes " + f"WHERE name = {_sql_literal(index_name)} " + f"AND object_id = OBJECT_ID({_sql_literal(obj)})" + ) + + +# --- Oracle --- + +def oracle_get_index_exists_sql( + index_name: str, table_name: str | None, database: str | None +) -> str: + """user_indexes (schema-global). The generic builder ALWAYS quotes + identifiers (quote_identifier), so Oracle stores them case-sensitively as + written — match the EXACT name, do NOT fold with UPPER() (a UPPER() match + would never find a quoted-lowercase index).""" + where = [f"index_name = {_sql_literal(index_name)}"] + if table_name: + where.append(f"table_name = {_sql_literal(table_name)}") + return f"SELECT COUNT(*) AS count FROM user_indexes WHERE {' AND '.join(where)}" + + +# --- SingleStore --- + +def singlestore_get_index_exists_sql( + index_name: str, table_name: str | None, database: str | None +) -> str: + """information_schema.STATISTICS (MySQL-compatible, table-scoped). Like + MySQL, ALWAYS constrain TABLE_SCHEMA — defaulting to DATABASE() when + `database` is omitted — so an index/table name shared across schemas cannot + produce a cross-schema false positive.""" + where = [f"INDEX_NAME = {_sql_literal(index_name)}"] + if table_name: + where.append(f"TABLE_NAME = {_sql_literal(table_name)}") + schema_pred = ( + f"TABLE_SCHEMA = {_sql_literal(database)}" if database else "TABLE_SCHEMA = DATABASE()" + ) + where.append(schema_pred) + return ( + "SELECT COUNT(*) AS count FROM information_schema.STATISTICS " + f"WHERE {' AND '.join(where)}" + ) + + # MotherDuck-specific list_tables override def motherduck_list_tables( ibis_backend: t.Any, diff --git a/tests/test_unit/backends/ibis/test_index_capability.py b/tests/test_unit/backends/ibis/test_index_capability.py index e99c8da..dae2b50 100644 --- a/tests/test_unit/backends/ibis/test_index_capability.py +++ b/tests/test_unit/backends/ibis/test_index_capability.py @@ -53,16 +53,13 @@ def test_dialectspec_index_caps_defaults_none(): "oracle": (DropScope.SCHEMA_GLOBAL, False, False, False, frozenset()), } -# Dialects that carry index_caps after THIS task. Task 5 appends the other 5. -_ASSIGNED_NOW = ["sqlite", "duckdb", "motherduck"] - _UNSUPPORTED = { "snowflake", "bigquery", "redshift", "trino", "clickhouse", "databricks", "exasol", "impala", "materialize", "risingwave", "druid", "pyspark", } -@pytest.mark.parametrize("name", _ASSIGNED_NOW) +@pytest.mark.parametrize("name", list(_EXPECTED)) def test_index_caps_matrix(name): caps = DIALECTS[name].index_caps assert caps is not None, f"{name} must have index_caps" @@ -79,9 +76,8 @@ def test_unsupported_dialects_have_no_index_caps(name): assert DIALECTS[name].index_caps is None -@pytest.mark.parametrize("name", _ASSIGNED_NOW) +@pytest.mark.parametrize("name", list(_EXPECTED)) def test_invariant_caps_implies_exists_sql(name): - """Spec §3 invariant: a dialect with index_caps must also introspect indexes.""" spec = DIALECTS[name] assert spec.index_caps is not None assert spec.get_index_exists_sql is not None diff --git a/tests/test_unit/backends/ibis/test_index_render.py b/tests/test_unit/backends/ibis/test_index_render.py index 3e39612..c7bdff7 100644 --- a/tests/test_unit/backends/ibis/test_index_render.py +++ b/tests/test_unit/backends/ibis/test_index_render.py @@ -107,3 +107,68 @@ def test_table_scoped(self): index_name="idx", target="`t`", guard="", ) assert sql == "DROP INDEX `idx` ON `t`" + + +from mountainash_data.backends.ibis.operations import ( # noqa: E402 + _sql_literal, + postgres_get_index_exists_sql, + mysql_get_index_exists_sql, + mssql_get_index_exists_sql, + oracle_get_index_exists_sql, + singlestore_get_index_exists_sql, + sqlite_get_index_exists_sql, +) + + +class TestIntrospectionSql: + def test_sql_literal_escapes_quote(self): + assert _sql_literal("x'y") == "'x''y'" + + def test_existing_sqlite_now_escapes(self): + sql = sqlite_get_index_exists_sql("a'b", "t", None) + assert "'a''b'" in sql + assert "count" in sql.lower() + + def test_postgres_shape_and_escaping(self): + sql = postgres_get_index_exists_sql("idx", "t", "public") + assert "pg_indexes" in sql + assert "'idx'" in sql and "'t'" in sql and "'public'" in sql + assert "count" in sql.lower() + + def test_mysql_is_table_scoped(self): + sql = mysql_get_index_exists_sql("idx", "t", None) + assert "STATISTICS" in sql.upper() + assert "'idx'" in sql and "'t'" in sql + + def test_mssql_uses_object_id(self): + sql = mssql_get_index_exists_sql("idx", "t", None) + assert "sys.indexes" in sql and "OBJECT_ID" in sql.upper() + + def test_oracle_matches_exact_quoted_name(self): + # Always-quoted create -> Oracle stores as written -> match exactly, no UPPER(). + sql = oracle_get_index_exists_sql("idx", "t", None) + assert "user_indexes" in sql.lower() + assert "UPPER" not in sql.upper() + assert "'idx'" in sql + + def test_singlestore_shape(self): + sql = singlestore_get_index_exists_sql("idx", "t", None) + assert "STATISTICS" in sql.upper() and "'t'" in sql + # always schema-constrained (defaults to DATABASE() when omitted) to + # avoid cross-schema false positives + assert "TABLE_SCHEMA = DATABASE()" in sql.upper() + + @pytest.mark.parametrize("fn", [ + postgres_get_index_exists_sql, mysql_get_index_exists_sql, + mssql_get_index_exists_sql, oracle_get_index_exists_sql, + singlestore_get_index_exists_sql, + ]) + def test_injection_payload_is_escaped_not_broken(self, fn): + # These pure SQL builders are ALLOWLIST-EXEMPT by design: the front-door + # rejection (the primary gate) is enforced by the generic dispatcher + # (_generic_index_exists) before any builder is called — see Task 6's + # `test_bad_identifier_rejected`. This test asserts the SECOND layer: + # even if a hostile value reached a builder, it is contained in an + # escaped literal (doubled quote), not interpolated raw. + sql = fn("x'; DROP TABLE t; --", "t", None) + assert "''" in sql From 5b1b5ed771b93034ce1e63d3449994940d949dab Mon Sep 17 00:00:00 2001 From: Nathaniel Ramm Date: Thu, 2 Jul 2026 12:48:34 +1000 Subject: [PATCH 07/11] feat(ibis): generic create/drop/exists dispatchers with emulation + validation Co-Authored-By: Claude Sonnet 4.6 --- src/mountainash_data/backends/ibis/_index.py | 164 +++++++++++++++++- .../test_unit/backends/ibis/test_index_ops.py | 101 +++++++++++ 2 files changed, 263 insertions(+), 2 deletions(-) create mode 100644 tests/test_unit/backends/ibis/test_index_ops.py diff --git a/src/mountainash_data/backends/ibis/_index.py b/src/mountainash_data/backends/ibis/_index.py index d8eab81..e943a4c 100644 --- a/src/mountainash_data/backends/ibis/_index.py +++ b/src/mountainash_data/backends/ibis/_index.py @@ -8,8 +8,13 @@ import typing as t -from mountainash_data.backends.ibis._render import quote_identifier -from mountainash_data.backends.ibis.dialects._registry import DropScope +from mountainash_data.backends.ibis._render import ( + compile_index_predicate, + dialect_of, + qualified_name, + quote_identifier, +) +from mountainash_data.backends.ibis.dialects._registry import DropScope, IndexCapability # USING position differs across dialects (verified against official docs): # - Postgres: CREATE INDEX i ON tbl USING gin (cols) -> after ON, before columns @@ -81,3 +86,158 @@ def build_drop_index_sql( if drop_scope is DropScope.TABLE_SCOPED: return f"DROP INDEX {guard}{name_sql} ON {target}" return f"DROP INDEX {guard}{name_sql}" + + +# --------------------------------------------------------------------------- +# Generic dispatchers (spec §5-§8) +# --------------------------------------------------------------------------- + +from mountainash_data.backends.ibis.operations import ( # noqa: E402 + _generate_index_name, + _normalize_columns, + _validate_simple_identifier, +) + + +def _generic_index_exists( + ibis_conn: t.Any, + index_name: str, + *, + table_name: t.Optional[str] = None, + database: t.Optional[str] = None, + exists_sql_fn: t.Any, +) -> bool: + """Run the dialect's introspection SQL and return whether the index exists.""" + if exists_sql_fn is None: + raise NotImplementedError("dialect has no get_index_exists_sql") + _validate_simple_identifier(index_name, kind="index_name") + if table_name is not None: + _validate_simple_identifier(table_name, kind="table_name") + if database is not None: + _validate_simple_identifier(database, kind="database") + result = ibis_conn.sql(exists_sql_fn(index_name, table_name, database)) + if result is None: + return False + import mountainash as ma + + # Read the single returned column BY POSITION, not by the alias name: + # Oracle upper-cases the unquoted `count` alias ("count" -> "COUNT"), so + # keying by "count" would KeyError. Every introspection query returns + # exactly one column. + data = ma.relation(result).to_dict() + first_col = next(iter(data.values())) + return first_col[0] > 0 + + +def _target_ref(ibis_conn: t.Any, table_name: str, database: t.Optional[str]) -> str: + dialect = dialect_of(ibis_conn) + parts = [database, table_name] if database else [table_name] + return qualified_name(parts, dialect) + + +def _generic_create_index( + ibis_conn: t.Any, + table_name: str, + columns: t.Union[list[str], str], + *, + index_name: t.Optional[str] = None, + unique: bool = False, + index_type: t.Optional[str] = None, + where: t.Any = None, + database: t.Optional[str] = None, + if_not_exists: bool = True, + caps: IndexCapability, + exists_sql_fn: t.Any, +) -> None: + """Render and execute a CREATE INDEX via the generic path (spec §5-§8). + + Emulation failure modes (TOCTOU / privilege / catalog-isolation / + auto-commit DDL) are documented-and-accepted per spec §6: the engine's + error is surfaced, never swallowed. + """ + _validate_simple_identifier(table_name, kind="table_name") + if database is not None: + _validate_simple_identifier(database, kind="database") + cols = _normalize_columns(columns) + for c in cols: + _validate_simple_identifier(c, kind="column") + + if index_type is not None and index_type not in caps.index_types: + raise ValueError( + f"index_type {index_type!r} not supported by this dialect; " + f"valid: {sorted(caps.index_types) or 'none'}" + ) + if where is not None and not caps.partial: + raise ValueError("this dialect does not support partial indexes (where=)") + + if index_name is None: + index_name = _generate_index_name(table_name, cols, unique=unique) + _validate_simple_identifier(index_name, kind="index_name") + + # Idempotency: native guard, or emulate via precheck. + guard = "" + if if_not_exists: + if caps.native_if_not_exists: + guard = "IF NOT EXISTS " + elif _generic_index_exists( + ibis_conn, index_name, table_name=table_name, database=database, + exists_sql_fn=exists_sql_fn, + ): + return # emulated: already present + + where_sql = None + if where is not None: + schema = ibis_conn.table(table_name, database=database).schema() + where_sql = compile_index_predicate(ibis_conn, schema, table_name, where) + + sql = build_create_index_sql( + dialect=dialect_of(ibis_conn), + target=_target_ref(ibis_conn, table_name, database), + index_name=index_name, cols=cols, unique=unique, + index_type=index_type, guard=guard, where_sql=where_sql, + ) + ibis_conn.raw_sql(sql) + + +def _generic_drop_index( + ibis_conn: t.Any, + index_name: str, + *, + table_name: t.Optional[str] = None, + database: t.Optional[str] = None, + if_exists: bool = True, + caps: IndexCapability, + exists_sql_fn: t.Any, +) -> None: + """Render and execute a DROP INDEX via the generic path (spec §5-§8). + + Emulation failure modes (TOCTOU / privilege / catalog-isolation / + auto-commit DDL) are documented-and-accepted per spec §6: the engine's + error is surfaced, never swallowed. + """ + _validate_simple_identifier(index_name, kind="index_name") + if caps.drop_scope is DropScope.TABLE_SCOPED and table_name is None: + raise ValueError( + "drop_index requires table_name for this dialect (DROP INDEX ... ON tbl)" + ) + if table_name is not None: + _validate_simple_identifier(table_name, kind="table_name") + if database is not None: + _validate_simple_identifier(database, kind="database") + + guard = "" + if if_exists: + if caps.native_if_exists: + guard = "IF EXISTS " + elif not _generic_index_exists( + ibis_conn, index_name, table_name=table_name, database=database, + exists_sql_fn=exists_sql_fn, + ): + return # emulated: already absent + + target = _target_ref(ibis_conn, table_name, database) if table_name else None + sql = build_drop_index_sql( + dialect=dialect_of(ibis_conn), drop_scope=caps.drop_scope, + index_name=index_name, target=target, guard=guard, + ) + ibis_conn.raw_sql(sql) diff --git a/tests/test_unit/backends/ibis/test_index_ops.py b/tests/test_unit/backends/ibis/test_index_ops.py new file mode 100644 index 0000000..986dcb4 --- /dev/null +++ b/tests/test_unit/backends/ibis/test_index_ops.py @@ -0,0 +1,101 @@ +"""Generic index dispatchers, exercised on in-memory sqlite/duckdb.""" + +import ibis +import polars as pl +import pytest + +from mountainash_data.backends.ibis._index import ( + _generic_create_index, + _generic_drop_index, + _generic_index_exists, +) +from mountainash_data.backends.ibis.dialects._registry import DIALECTS + +_SQLITE = DIALECTS["sqlite"].index_caps +_SQLITE_FN = DIALECTS["sqlite"].get_index_exists_sql +_DUCKDB = DIALECTS["duckdb"].index_caps +_DUCKDB_FN = DIALECTS["duckdb"].get_index_exists_sql + + +def _seed_sqlite(): + con = ibis.sqlite.connect() + con.create_table("t", pl.DataFrame({"id": [1, 2], "active": [True, False]})) + return con + + +class TestCreateDropExistsRoundtrip: + def test_create_then_exists_then_drop(self): + con = _seed_sqlite() + _generic_create_index( + con, "t", ["id"], index_name="idx_t_id", caps=_SQLITE, + exists_sql_fn=_SQLITE_FN, + ) + assert _generic_index_exists(con, "idx_t_id", table_name="t", + exists_sql_fn=_SQLITE_FN) is True + _generic_drop_index(con, "idx_t_id", table_name="t", caps=_SQLITE, + exists_sql_fn=_SQLITE_FN) + assert _generic_index_exists(con, "idx_t_id", table_name="t", + exists_sql_fn=_SQLITE_FN) is False + + def test_create_if_not_exists_is_idempotent_native(self): + con = _seed_sqlite() + for _ in range(2): + _generic_create_index( + con, "t", ["id"], index_name="idx_t_id", if_not_exists=True, + caps=_SQLITE, exists_sql_fn=_SQLITE_FN, + ) # second call must not raise (native IF NOT EXISTS) + + def test_default_index_name_generated(self): + con = _seed_sqlite() + _generic_create_index(con, "t", ["id"], caps=_SQLITE, exists_sql_fn=_SQLITE_FN) + assert _generic_index_exists(con, "idx_t_id", table_name="t", + exists_sql_fn=_SQLITE_FN) is True + + +class TestPartialIndex: + def test_partial_where_on_sqlite(self): + con = _seed_sqlite() + _generic_create_index( + con, "t", ["id"], index_name="idx_active", + where=lambda r: r.active == True, caps=_SQLITE, # noqa: E712 + exists_sql_fn=_SQLITE_FN, + ) + assert _generic_index_exists(con, "idx_active", table_name="t", + exists_sql_fn=_SQLITE_FN) is True + + def test_where_on_non_partial_dialect_raises(self): + con = ibis.duckdb.connect() + con.create_table("t", pl.DataFrame({"id": [1], "active": [True]})) + with pytest.raises(ValueError, match="partial"): + _generic_create_index( + con, "t", ["id"], where=lambda r: r.active, caps=_DUCKDB, + exists_sql_fn=_DUCKDB_FN, + ) + + +class TestValidationErrors: + def test_unsupported_index_type_raises(self): + con = _seed_sqlite() + with pytest.raises(ValueError, match="index_type"): + _generic_create_index( + con, "t", ["id"], index_type="hash", caps=_SQLITE, + exists_sql_fn=_SQLITE_FN, + ) + + def test_table_scoped_drop_requires_table_name(self): + con = _seed_sqlite() + mysql_caps = DIALECTS["mysql"].index_caps + with pytest.raises(ValueError, match="table_name"): + _generic_drop_index(con, "idx", table_name=None, caps=mysql_caps, + exists_sql_fn=DIALECTS["mysql"].get_index_exists_sql) + + def test_bad_identifier_rejected(self): + con = _seed_sqlite() + with pytest.raises(ValueError, match="simple identifier"): + _generic_create_index(con, "t", ["id"], index_name="x; DROP", + caps=_SQLITE, exists_sql_fn=_SQLITE_FN) + + def test_drop_if_exists_absent_is_noop_native(self): + con = _seed_sqlite() + _generic_drop_index(con, "nope", table_name="t", if_exists=True, + caps=_SQLITE, exists_sql_fn=_SQLITE_FN) # no raise From dc77a3c32f0ff6817ff943588a2f3822fc484e2f Mon Sep 17 00:00:00 2001 From: Nathaniel Ramm Date: Thu, 2 Jul 2026 12:55:12 +1000 Subject: [PATCH 08/11] feat(ibis): wire generic index dispatch + atomic cutover of duckdb_family hooks --- src/mountainash_data/backends/ibis/backend.py | 71 ++++++++++++------- .../backends/ibis/dialects/_registry.py | 8 --- .../backends/ibis/operations.py | 65 ----------------- tests/test_unit/backends/ibis/test_backend.py | 27 ++++++- .../test_unit/backends/ibis/test_index_ops.py | 48 +++++++++++++ 5 files changed, 117 insertions(+), 102 deletions(-) diff --git a/src/mountainash_data/backends/ibis/backend.py b/src/mountainash_data/backends/ibis/backend.py index e8acb35..cf3aecd 100644 --- a/src/mountainash_data/backends/ibis/backend.py +++ b/src/mountainash_data/backends/ibis/backend.py @@ -12,6 +12,11 @@ from mountainash_data.backends.ibis.dialects._registry import DIALECTS, DialectSpec from mountainash_data.backends.ibis.operations import _generic_add_columns, _generic_rename_table, _generic_upsert +from mountainash_data.backends.ibis._index import ( + _generic_create_index, + _generic_drop_index, + _generic_index_exists, +) from mountainash_data.core.inspection import ( CatalogInfo, NamespaceInfo, @@ -581,21 +586,30 @@ def create_index( index_name: str | None = None, unique: bool = False, index_type: str | None = None, - where_condition: str | None = None, + where: t.Any = None, # IndexPredicate | None database: str | None = None, if_not_exists: bool = True, ) -> IbisBackend: - if self._spec.create_index_hook is None: + conn = self._require_connected() + hook = self._spec.create_index_hook + if hook is not None: + hook( + conn._ibis_conn, table_name, columns, + index_name=index_name, unique=unique, index_type=index_type, + where=where, database=database, if_not_exists=if_not_exists, + ) + elif self._spec.index_caps is not None: + _generic_create_index( + conn._ibis_conn, table_name, columns, + index_name=index_name, unique=unique, index_type=index_type, + where=where, database=database, if_not_exists=if_not_exists, + caps=self._spec.index_caps, + exists_sql_fn=self._spec.get_index_exists_sql, + ) + else: raise NotImplementedError( f"Dialect {self.dialect!r} does not support create_index" ) - conn = self._require_connected() - self._spec.create_index_hook( - conn._ibis_conn, table_name, columns, - index_name=index_name, unique=unique, index_type=index_type, - where_condition=where_condition, database=database, - if_not_exists=if_not_exists, - ) return self def create_unique_index( @@ -604,13 +618,12 @@ def create_unique_index( columns: list[str] | str, *, index_name: str | None = None, - where_condition: str | None = None, + where: t.Any = None, # IndexPredicate | None database: str | None = None, ) -> IbisBackend: return self.create_index( table_name, columns, - index_name=index_name, unique=True, - where_condition=where_condition, database=database, + index_name=index_name, unique=True, where=where, database=database, ) def drop_index( @@ -621,15 +634,24 @@ def drop_index( database: str | None = None, if_exists: bool = True, ) -> IbisBackend: - if self._spec.drop_index_hook is None: + conn = self._require_connected() + hook = self._spec.drop_index_hook + if hook is not None: + hook( + conn._ibis_conn, index_name, + table_name=table_name, database=database, if_exists=if_exists, + ) + elif self._spec.index_caps is not None: + _generic_drop_index( + conn._ibis_conn, index_name, + table_name=table_name, database=database, if_exists=if_exists, + caps=self._spec.index_caps, + exists_sql_fn=self._spec.get_index_exists_sql, + ) + else: raise NotImplementedError( f"Dialect {self.dialect!r} does not support drop_index" ) - conn = self._require_connected() - self._spec.drop_index_hook( - conn._ibis_conn, index_name, - table_name=table_name, database=database, if_exists=if_exists, - ) return self def index_exists( @@ -644,14 +666,11 @@ def index_exists( f"Dialect {self.dialect!r} does not support index_exists" ) conn = self._require_connected() - # pre-existing: hook signature types table_name as str; not migration scope - check_sql = self._spec.get_index_exists_sql(index_name, table_name, database) # type: ignore[arg-type] - result = conn._ibis_conn.sql(check_sql) - if result is None: - return False - import mountainash as ma - count = ma.relation(result).to_dict()["count"][0] - return count > 0 + return _generic_index_exists( + conn._ibis_conn, index_name, + table_name=table_name, database=database, + exists_sql_fn=self._spec.get_index_exists_sql, + ) def list_indexes( self, diff --git a/src/mountainash_data/backends/ibis/dialects/_registry.py b/src/mountainash_data/backends/ibis/dialects/_registry.py index 9d74397..eda0d7a 100644 --- a/src/mountainash_data/backends/ibis/dialects/_registry.py +++ b/src/mountainash_data/backends/ibis/dialects/_registry.py @@ -676,8 +676,6 @@ def _build_pyspark_connection(**config: t.Any) -> t.Any: mssql_get_index_exists_sql, oracle_get_index_exists_sql, singlestore_get_index_exists_sql, - duckdb_family_create_index, - duckdb_family_drop_index, ) @@ -690,8 +688,6 @@ def _build_pyspark_connection(**config: t.Any) -> t.Any: get_index_exists_sql=sqlite_get_index_exists_sql, get_list_indexes_sql=sqlite_get_list_indexes_sql, upsert_style=UpsertStyle.ON_CONFLICT, - create_index_hook=duckdb_family_create_index, - drop_index_hook=duckdb_family_drop_index, index_caps=IndexCapability( drop_scope=DropScope.SCHEMA_GLOBAL, partial=True, native_if_not_exists=True, native_if_exists=True, @@ -706,8 +702,6 @@ def _build_pyspark_connection(**config: t.Any) -> t.Any: get_index_exists_sql=duckdb_get_index_exists_sql, get_list_indexes_sql=duckdb_get_list_indexes_sql, upsert_style=UpsertStyle.ON_CONFLICT, - create_index_hook=duckdb_family_create_index, - drop_index_hook=duckdb_family_drop_index, index_caps=IndexCapability( drop_scope=DropScope.SCHEMA_GLOBAL, partial=False, native_if_not_exists=True, native_if_exists=True, @@ -722,8 +716,6 @@ def _build_pyspark_connection(**config: t.Any) -> t.Any: get_index_exists_sql=motherduck_get_index_exists_sql, get_list_indexes_sql=motherduck_get_list_indexes_sql, upsert_style=UpsertStyle.ON_CONFLICT, - create_index_hook=duckdb_family_create_index, - drop_index_hook=duckdb_family_drop_index, index_caps=IndexCapability( drop_scope=DropScope.SCHEMA_GLOBAL, partial=False, native_if_not_exists=True, native_if_exists=True, diff --git a/src/mountainash_data/backends/ibis/operations.py b/src/mountainash_data/backends/ibis/operations.py index e89a098..700aef1 100644 --- a/src/mountainash_data/backends/ibis/operations.py +++ b/src/mountainash_data/backends/ibis/operations.py @@ -3,21 +3,15 @@ Contains: - Module-level helper functions: _generate_index_name, _format_qualified_table, _normalize_columns - Per-dialect SQL functions: duckdb, sqlite, motherduck index SQL generators -- Standalone hook functions: duckdb_family_create_index, duckdb_family_drop_index - Generic, dialect-agnostic write ops: _generic_rename_table, _generic_add_columns, _generic_upsert (with the three upsert-family renderers + MySQL preflight) """ import typing as t -import contextlib import re import warnings import ibis - -from mountainash_data.core.constants import ( - CONST_INDEX_TYPE, -) from sqlglot import exp from mountainash_data.backends.ibis._render import ( ConditionAliases, @@ -452,65 +446,6 @@ def motherduck_list_tables( return ibis_backend.list_tables(like=like, database=database) if ibis_backend is not None else [] -# =========================================================================== -# STANDALONE HOOK FUNCTIONS -# =========================================================================== - -def duckdb_family_create_index( - ibis_conn: t.Any, - table_name: str, - columns: list[str] | str, - *, - index_name: str | None = None, - unique: bool = False, - index_type: str | None = None, - where_condition: str | None = None, - database: str | None = None, - if_not_exists: bool = True, -) -> None: - """Create an index using DuckDB/SQLite syntax.""" - columns_list = _normalize_columns(columns) - - if index_name is None: - index_name = _generate_index_name(table_name, columns_list, unique=unique) - - qualified_table = _format_qualified_table(table_name, database=database) - columns_sql = ", ".join(columns_list) - - unique_sql = "UNIQUE " if unique else "" - if_not_exists_sql = "IF NOT EXISTS " if if_not_exists else "" - where_sql = f" WHERE {where_condition}" if where_condition else "" - - if index_type and index_type != CONST_INDEX_TYPE.BTREE: - warnings.warn( - f"Index type {index_type} not supported, using default BTREE" - ) - - create_sql = ( - f"CREATE {unique_sql}INDEX {if_not_exists_sql}{index_name} " - f"ON {qualified_table} ({columns_sql}){where_sql}" - ) - - with contextlib.closing(ibis_conn.con.cursor()) as cur: - cur.execute(create_sql) - - -def duckdb_family_drop_index( - ibis_conn: t.Any, - index_name: str, - *, - table_name: str | None = None, - database: str | None = None, - if_exists: bool = True, -) -> None: - """Drop an index using DuckDB/SQLite syntax.""" - if_exists_sql = "IF EXISTS " if if_exists else "" - drop_sql = f"DROP INDEX {if_exists_sql}{index_name}" - - with contextlib.closing(ibis_conn.con.cursor()) as cur: - cur.execute(drop_sql) - - # =========================================================================== # GENERIC UPSERT — dialect-agnostic dispatcher # =========================================================================== diff --git a/tests/test_unit/backends/ibis/test_backend.py b/tests/test_unit/backends/ibis/test_backend.py index 1e4f191..051a6b8 100644 --- a/tests/test_unit/backends/ibis/test_backend.py +++ b/tests/test_unit/backends/ibis/test_backend.py @@ -226,10 +226,31 @@ def test_duckdb_dialect_routes_generic_upsert(): assert spec.upsert_style == UpsertStyle.ON_CONFLICT -def test_sqlite_dialect_has_create_index_hook(): - """SQLite DialectSpec must have create_index_hook wired.""" +def test_sqlite_dialect_uses_generic_index_path(): + """After cutover, sqlite has no index hooks and dispatches via index_caps.""" spec = DIALECTS["sqlite"] - assert spec.create_index_hook is not None + assert spec.create_index_hook is None + assert spec.drop_index_hook is None + assert spec.index_caps is not None + + +def test_duckdb_family_index_hooks_removed(): + import mountainash_data.backends.ibis.operations as ops + assert not hasattr(ops, "duckdb_family_create_index") + assert not hasattr(ops, "duckdb_family_drop_index") + + +def test_no_dialect_carries_an_index_hook_post_cutover(): + """The generic path is the ONLY index path after cutover: no dialect carries + a create/drop index hook, so the backend's hook-first branch (which forwards + the new `where=` predicate) is never exercised — keeping it dead and safe. + The hook fields remain only as a future override escape hatch; CONTRACT: any + future create_index_hook MUST accept create_index's keyword signature, + including `where` (the ibis predicate), and any drop_index_hook MUST accept + `table_name`/`database`/`if_exists`.""" + for name, spec in DIALECTS.items(): + assert spec.create_index_hook is None, f"{name} unexpectedly has create_index_hook" + assert spec.drop_index_hook is None, f"{name} unexpectedly has drop_index_hook" def test_postgres_dialect_has_no_upsert_hook(): diff --git a/tests/test_unit/backends/ibis/test_index_ops.py b/tests/test_unit/backends/ibis/test_index_ops.py index 986dcb4..55b62ea 100644 --- a/tests/test_unit/backends/ibis/test_index_ops.py +++ b/tests/test_unit/backends/ibis/test_index_ops.py @@ -99,3 +99,51 @@ def test_drop_if_exists_absent_is_noop_native(self): con = _seed_sqlite() _generic_drop_index(con, "nope", table_name="t", if_exists=True, caps=_SQLITE, exists_sql_fn=_SQLITE_FN) # no raise + + +from mountainash_data import IbisBackend # noqa: E402 + + +class TestBackendDispatch: + def test_create_exists_drop_via_backend(self): + be = IbisBackend(dialect="sqlite", database=":memory:") + be.connect() + try: + be.create_table("t", pl.DataFrame({"id": [1], "active": [True]}), + overwrite=True) + assert be.create_index("t", ["id"], index_name="ix") is be + assert be.index_exists("ix", table_name="t") is True + assert be.drop_index("ix", table_name="t") is be + assert be.index_exists("ix", table_name="t") is False + finally: + be.close() + + def test_where_predicate_via_backend(self): + be = IbisBackend(dialect="sqlite", database=":memory:") + be.connect() + try: + be.create_table("t", pl.DataFrame({"id": [1], "active": [True]}), + overwrite=True) + be.create_index("t", ["id"], index_name="ixp", + where=lambda r: r.active == True) # noqa: E712 + assert be.index_exists("ixp", table_name="t") is True + finally: + be.close() + + def test_unsupported_dialect_raises_notimplemented(self): + from mountainash_data.backends.ibis.dialects._registry import DialectSpec + be = IbisBackend(dialect="sqlite", database=":memory:") + be.connect() + try: + # Rebind the INSTANCE's _spec to a fresh no-index spec (index_caps and + # create_index_hook default to None). Never mutate the shared frozen + # singleton in DIALECTS — that would corrupt other tests. + be._spec = DialectSpec( + ibis_backend_name="sqlite", + connection_mode="connection_string", + connection_string_scheme="sqlite://", + ) + with pytest.raises(NotImplementedError): + be.create_index("t", ["id"]) + finally: + be.close() From fb7c3c04a1dfa6f08d29613caf438fdc588b8c02 Mon Sep 17 00:00:00 2001 From: Nathaniel Ramm Date: Thu, 2 Jul 2026 13:00:51 +1000 Subject: [PATCH 09/11] test(ibis): live index round-trips (postgres native + mariadb emulated/table-scoped) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix: normalise sqlglot dialect class → lowercase name string in build_create_index_sql so USING-position frozenset membership checks work on the live path (dialect_of() returns a class, not a string; str() was producing the class repr, silently skipping the postgres USING-before-columns branch and emitting invalid SQL). Co-Authored-By: Claude Sonnet 4.6 --- src/mountainash_data/backends/ibis/_index.py | 9 ++- tests/test_integration/test_index_ops_live.py | 62 +++++++++++++++++++ 2 files changed, 70 insertions(+), 1 deletion(-) create mode 100644 tests/test_integration/test_index_ops_live.py diff --git a/src/mountainash_data/backends/ibis/_index.py b/src/mountainash_data/backends/ibis/_index.py index e943a4c..034ca25 100644 --- a/src/mountainash_data/backends/ibis/_index.py +++ b/src/mountainash_data/backends/ibis/_index.py @@ -53,7 +53,14 @@ def build_create_index_sql( name_sql = quote_identifier(index_name, dialect) where = f" WHERE {where_sql}" if where_sql else "" name_part = f"{guard}{name_sql}" - d = str(dialect) + # dialect may be a sqlglot Dialect class (live path) or a plain string (tests/golden). + # Normalise to the lowercase name so membership checks work in both cases. + if isinstance(dialect, type): + d = dialect.__name__.lower() + elif isinstance(dialect, str): + d = dialect.lower() + else: + d = str(dialect).lower() using = f"USING {index_type}" if index_type else None if using and d in _USING_BEFORE_ON: diff --git a/tests/test_integration/test_index_ops_live.py b/tests/test_integration/test_index_ops_live.py new file mode 100644 index 0000000..898747b --- /dev/null +++ b/tests/test_integration/test_index_ops_live.py @@ -0,0 +1,62 @@ +"""Live index ops against postgres (native) and mariadb (table-scoped + emulated).""" + +import polars as pl +import pytest + +pytestmark = pytest.mark.integration + + +def _fresh_table(be, name): + # the raw ibis connection lives on the IbisConnection, not on the backend + conn = be._require_connected()._ibis_conn + try: + conn.drop_table(name, force=True) + except Exception: # noqa: BLE001 + pass + conn.create_table(name, pl.DataFrame({"id": [1, 2, 3], "active": [True, False, True]})) + + +class TestPostgresLive: + def test_roundtrip_and_partial(self, postgres_backend): + be = postgres_backend + _fresh_table(be, "ix_live") + be.create_index("ix_live", ["id"], index_name="ix_live_id") + assert be.index_exists("ix_live_id", table_name="ix_live") is True + # partial (filtered) index — postgres supports WHERE + be.create_index("ix_live", ["id"], index_name="ix_live_active", + where=lambda r: r.active == True) # noqa: E712 + assert be.index_exists("ix_live_active", table_name="ix_live") is True + be.drop_index("ix_live_id") # schema-global: no table needed + assert be.index_exists("ix_live_id", table_name="ix_live") is False + + def test_using_gin_index_type(self, postgres_backend): + be = postgres_backend + _fresh_table(be, "ix_gin") + be.create_index("ix_gin", ["id"], index_name="ix_gin_btree", index_type="btree") + assert be.index_exists("ix_gin_btree", table_name="ix_gin") is True + + +class TestMariaDBLive: + def test_table_scoped_drop_requires_table(self, mysql_backend): + be = mysql_backend + _fresh_table(be, "ix_my") + be.create_index("ix_my", ["id"], index_name="ix_my_id") + assert be.index_exists("ix_my_id", table_name="ix_my") is True + # schema-global drop must be rejected for a TABLE_SCOPED dialect + with pytest.raises(ValueError, match="table_name"): + be.drop_index("ix_my_id") + be.drop_index("ix_my_id", table_name="ix_my") + assert be.index_exists("ix_my_id", table_name="ix_my") is False + + def test_emulated_if_not_exists_is_idempotent(self, mysql_backend): + be = mysql_backend + _fresh_table(be, "ix_emu") + # mysql dialect emulates IF NOT EXISTS via precheck; double-create is a no-op + be.create_index("ix_emu", ["id"], index_name="ix_emu_id", if_not_exists=True) + be.create_index("ix_emu", ["id"], index_name="ix_emu_id", if_not_exists=True) + assert be.index_exists("ix_emu_id", table_name="ix_emu") is True + + def test_emulated_if_exists_drop_absent_is_noop(self, mysql_backend): + be = mysql_backend + _fresh_table(be, "ix_emu2") + be.drop_index("nope", table_name="ix_emu2", if_exists=True) # no raise From f7fca27bb399bc4367a9d1cdf1fe5180f273c03b Mon Sep 17 00:00:00 2001 From: Nathaniel Ramm Date: Thu, 2 Jul 2026 13:04:33 +1000 Subject: [PATCH 10/11] test(ibis): unit regression guard for dialect-class USING placement Add test_using_placement_with_sqlglot_dialect_class to validate that build_create_index_sql correctly normalizes sqlglot Dialect classes (not just strings) when checking USING placement. The live path (dialect_of(ibis_conn)) returns Dialect classes, so this guards against regressions on the class.__name__.lower() normalization. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../backends/ibis/test_index_render.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/test_unit/backends/ibis/test_index_render.py b/tests/test_unit/backends/ibis/test_index_render.py index c7bdff7..72d62c3 100644 --- a/tests/test_unit/backends/ibis/test_index_render.py +++ b/tests/test_unit/backends/ibis/test_index_render.py @@ -92,6 +92,25 @@ def test_using_after_columns_singlestore(self): ) assert sql == "CREATE INDEX `i` ON `t` (`id`) USING hash" + def test_using_placement_with_sqlglot_dialect_class(self): + # dialect_of(ibis_conn) returns a sqlglot Dialect CLASS, not a string. + # Regression guard: the class must normalise to its lowercase name so + # USING placement matches (bug: str(class) never matched the frozensets). + from sqlglot.dialects.postgres import Postgres + from sqlglot.dialects.mysql import MySQL + + pg = build_create_index_sql( + dialect=Postgres, target='"t"', index_name="g", cols=["doc"], + unique=False, index_type="gin", guard="", where_sql=None, + ) + assert pg == 'CREATE INDEX "g" ON "t" USING gin ("doc")' # USING before columns + + my = build_create_index_sql( + dialect=MySQL, target="`t`", index_name="i", cols=["id"], + unique=False, index_type="btree", guard="", where_sql=None, + ) + assert my == "CREATE INDEX `i` USING btree ON `t` (`id`)" # USING before ON + class TestBuildDropIndexSql: def test_schema_global(self): From c6043a15ea98761563ce7ca5ecc09a61b3bad509 Mon Sep 17 00:00:00 2001 From: Nathaniel Ramm Date: Thu, 2 Jul 2026 13:12:38 +1000 Subject: [PATCH 11/11] =?UTF-8?q?chore(ibis):=20final-review=20cleanup=20?= =?UTF-8?q?=E2=80=94=20top-level=20imports,=20tighter=20index=20tests,=20d?= =?UTF-8?q?oc=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move mid-file operations import in _index.py to module top; remove false noqa/circular-import comment (no cycle exists) - Tighten test_partial_where to full string equality (== not endswith) - Add TABLE_SCHEMA = DATABASE() parity assertion to test_mysql_is_table_scoped - Replace removed where_condition= param with where= predicate form in TEST_COVERAGE_UPSERT_INDEXES.md Co-Authored-By: Claude Opus 4.8 (1M context) --- src/mountainash_data/backends/ibis/_index.py | 11 +++++------ tests/TEST_COVERAGE_UPSERT_INDEXES.md | 2 +- tests/test_unit/backends/ibis/test_index_render.py | 3 ++- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/mountainash_data/backends/ibis/_index.py b/src/mountainash_data/backends/ibis/_index.py index 034ca25..5e9d0c4 100644 --- a/src/mountainash_data/backends/ibis/_index.py +++ b/src/mountainash_data/backends/ibis/_index.py @@ -15,6 +15,11 @@ quote_identifier, ) from mountainash_data.backends.ibis.dialects._registry import DropScope, IndexCapability +from mountainash_data.backends.ibis.operations import ( + _generate_index_name, + _normalize_columns, + _validate_simple_identifier, +) # USING position differs across dialects (verified against official docs): # - Postgres: CREATE INDEX i ON tbl USING gin (cols) -> after ON, before columns @@ -99,12 +104,6 @@ def build_drop_index_sql( # Generic dispatchers (spec §5-§8) # --------------------------------------------------------------------------- -from mountainash_data.backends.ibis.operations import ( # noqa: E402 - _generate_index_name, - _normalize_columns, - _validate_simple_identifier, -) - def _generic_index_exists( ibis_conn: t.Any, diff --git a/tests/TEST_COVERAGE_UPSERT_INDEXES.md b/tests/TEST_COVERAGE_UPSERT_INDEXES.md index b30c115..226d7bf 100644 --- a/tests/TEST_COVERAGE_UPSERT_INDEXES.md +++ b/tests/TEST_COVERAGE_UPSERT_INDEXES.md @@ -158,7 +158,7 @@ operations.create_unique_index(backend, "users", ["email"]) # Create partial index operations.create_index( backend, "orders", ["customer_id"], - where_condition="status = 'active'" + where=lambda r: r.status == "active" ) # Check existence diff --git a/tests/test_unit/backends/ibis/test_index_render.py b/tests/test_unit/backends/ibis/test_index_render.py index 72d62c3..e3c3c7e 100644 --- a/tests/test_unit/backends/ibis/test_index_render.py +++ b/tests/test_unit/backends/ibis/test_index_render.py @@ -67,7 +67,7 @@ def test_partial_where(self): dialect="duckdb", target='"t"', index_name="p", cols=["id"], unique=False, index_type=None, guard="", where_sql='"active"', ) - assert sql.endswith('("id") WHERE "active"') + assert sql == 'CREATE INDEX "p" ON "t" ("id") WHERE "active"' def test_using_before_columns_postgres(self): sql = build_create_index_sql( @@ -158,6 +158,7 @@ def test_mysql_is_table_scoped(self): sql = mysql_get_index_exists_sql("idx", "t", None) assert "STATISTICS" in sql.upper() assert "'idx'" in sql and "'t'" in sql + assert "TABLE_SCHEMA = DATABASE()" in sql.upper() def test_mssql_uses_object_id(self): sql = mssql_get_index_exists_sql("idx", "t", None)