diff --git a/src/mountainash_data/backends/ibis/_index.py b/src/mountainash_data/backends/ibis/_index.py new file mode 100644 index 0000000..5e9d0c4 --- /dev/null +++ b/src/mountainash_data/backends/ibis/_index.py @@ -0,0 +1,249 @@ +"""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 ( + compile_index_predicate, + dialect_of, + qualified_name, + 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 +# - 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}" + # 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: + # 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}" + + +# --------------------------------------------------------------------------- +# Generic dispatchers (spec §5-§8) +# --------------------------------------------------------------------------- + + +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/src/mountainash_data/backends/ibis/_render.py b/src/mountainash_data/backends/ibis/_render.py index d1444f7..9dc194a 100644 --- a/src/mountainash_data/backends/ibis/_render.py +++ b/src/mountainash_data/backends/ibis/_render.py @@ -209,3 +209,60 @@ 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. 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 + 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/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 bca0004..eda0d7a 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 @@ -649,8 +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, - duckdb_family_create_index, - duckdb_family_drop_index, + 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, ) @@ -663,8 +688,11 @@ 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, + index_types=frozenset(), + ), ), "duckdb": DialectSpec( ibis_backend_name="duckdb", @@ -674,8 +702,11 @@ 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, + index_types=frozenset(), + ), ), "motherduck": DialectSpec( ibis_backend_name="duckdb", @@ -685,8 +716,11 @@ 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, + index_types=frozenset(), + ), ), "postgres": DialectSpec( ibis_backend_name="postgres", @@ -694,6 +728,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", @@ -701,6 +741,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", @@ -708,6 +754,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", @@ -715,6 +767,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", @@ -763,6 +821,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..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, @@ -159,6 +153,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 +239,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 +276,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 +311,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,73 +341,109 @@ def motherduck_get_list_indexes_sql( """ -# MotherDuck-specific list_tables override -def motherduck_list_tables( - ibis_backend: t.Any, - like: str | None = None, - database: str | None = None, -) -> list[str]: - """MotherDuck-specific list_tables using DuckDB backend's database parameter.""" - return ibis_backend.list_tables(like=like, database=database) if ibis_backend is not None else [] - +# --- PostgreSQL --- -# =========================================================================== -# STANDALONE HOOK FUNCTIONS -# =========================================================================== +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)}" -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) +# --- MySQL / MariaDB --- - qualified_table = _format_qualified_table(table_name, database=database) - columns_sql = ", ".join(columns_list) +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)}" + ) - 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" - ) +# --- SQL Server --- - create_sql = ( - f"CREATE {unique_sql}INDEX {if_not_exists_sql}{index_name} " - f"ON {qualified_table} ({columns_sql}){where_sql}" +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)})" ) - with contextlib.closing(ibis_conn.con.cursor()) as cur: - cur.execute(create_sql) +# --- Oracle --- -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}" +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 --- - with contextlib.closing(ibis_conn.con.cursor()) as cur: - cur.execute(drop_sql) +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, + like: str | None = None, + database: str | None = None, +) -> list[str]: + """MotherDuck-specific list_tables using DuckDB backend's database parameter.""" + return ibis_backend.list_tables(like=like, database=database) if ibis_backend is not None else [] # =========================================================================== 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_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 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_capability.py b/tests/test_unit/backends/ibis/test_index_capability.py new file mode 100644 index 0000000..dae2b50 --- /dev/null +++ b/tests/test_unit/backends/ibis/test_index_capability.py @@ -0,0 +1,93 @@ +"""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 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()), +} + +_UNSUPPORTED = { + "snowflake", "bigquery", "redshift", "trino", "clickhouse", "databricks", + "exasol", "impala", "materialize", "risingwave", "druid", "pyspark", +} + + +@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" + 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", list(_EXPECTED)) +def test_invariant_caps_implies_exists_sql(name): + 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" + ) 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..55b62ea --- /dev/null +++ b/tests/test_unit/backends/ibis/test_index_ops.py @@ -0,0 +1,149 @@ +"""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 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() 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..e3c3c7e --- /dev/null +++ b/tests/test_unit/backends/ibis/test_index_render.py @@ -0,0 +1,194 @@ +"""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 + 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"): + _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 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 == 'CREATE INDEX "p" ON "t" ("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" + + 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): + 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 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 + assert "TABLE_SCHEMA = DATABASE()" in sql.upper() + + 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