diff --git a/src/mountainash_data/__init__.py b/src/mountainash_data/__init__.py index 5dfecc5..2705679 100644 --- a/src/mountainash_data/__init__.py +++ b/src/mountainash_data/__init__.py @@ -11,6 +11,7 @@ from mountainash_data.core.inspection import ( CatalogInfo, ColumnInfo, + IndexInfo, NamespaceInfo, TableInfo, ) @@ -22,6 +23,7 @@ "Backend", "CatalogInfo", "ColumnInfo", + "IndexInfo", "NamespaceInfo", "TableInfo", "Namespace", diff --git a/src/mountainash_data/backends/ibis/_index.py b/src/mountainash_data/backends/ibis/_index.py index 8a07770..a072460 100644 --- a/src/mountainash_data/backends/ibis/_index.py +++ b/src/mountainash_data/backends/ibis/_index.py @@ -40,6 +40,7 @@ def build_create_index_sql( index_type: t.Optional[str], guard: str, where_sql: t.Optional[str], + index_ref: t.Optional[str] = None, ) -> str: """Render a CREATE INDEX statement from pre-validated parts. @@ -55,7 +56,7 @@ def build_create_index_sql( """ unique_sql = "UNIQUE " if unique else "" cols_sql = ", ".join(quote_identifier(c, dialect) for c in cols) - name_sql = quote_identifier(index_name, dialect) + name_sql = index_ref or 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). @@ -91,10 +92,10 @@ def build_drop_index_sql( index_name: str, target: t.Optional[str], guard: str, + index_ref: t.Optional[str] = None, ) -> str: - """Render a DROP INDEX statement. `target` is required (already quoted) when - `drop_scope` is TABLE_SCOPED.""" - name_sql = quote_identifier(index_name, dialect) + """Render a DROP INDEX statement with an optional qualified index ref.""" + name_sql = index_ref or 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}" @@ -132,13 +133,31 @@ def _generic_index_exists( first_col = result.to_pyarrow().column(0).to_pylist() return first_col[0] > 0 - def _target_ref(ibis_conn: t.Any, table_name: str, namespace: t.Optional[str]) -> str: dialect = dialect_of(ibis_conn) + dialect_name = ( + dialect.__name__.lower() + if isinstance(dialect, type) + else str(dialect).lower() + ) + if dialect_name == "sqlite": + return quote_identifier(table_name, dialect) parts = [namespace, table_name] if namespace else [table_name] return qualified_name(parts, dialect) +def _index_ref(ibis_conn: t.Any, index_name: str, namespace: t.Optional[str]) -> str: + dialect = dialect_of(ibis_conn) + dialect_name = ( + dialect.__name__.lower() + if isinstance(dialect, type) + else str(dialect).lower() + ) + if dialect_name == "sqlite" and namespace: + return qualified_name([namespace, index_name], dialect) + return quote_identifier(index_name, dialect) + + def _generic_create_index( ibis_conn: t.Any, table_name: str, @@ -197,8 +216,13 @@ def _generic_create_index( sql = build_create_index_sql( dialect=dialect_of(ibis_conn), target=_target_ref(ibis_conn, table_name, namespace), - index_name=index_name, cols=cols, unique=unique, - index_type=index_type, guard=guard, where_sql=where_sql, + index_name=index_name, + cols=cols, + unique=unique, + index_type=index_type, + guard=guard, + where_sql=where_sql, + index_ref=_index_ref(ibis_conn, index_name, namespace), ) ibis_conn.raw_sql(sql) @@ -241,7 +265,11 @@ def _generic_drop_index( target = _target_ref(ibis_conn, table_name, namespace) 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, + dialect=dialect_of(ibis_conn), + drop_scope=caps.drop_scope, + index_name=index_name, + target=target, + guard=guard, + index_ref=_index_ref(ibis_conn, index_name, namespace), ) ibis_conn.raw_sql(sql) diff --git a/src/mountainash_data/backends/ibis/_index_inspection.py b/src/mountainash_data/backends/ibis/_index_inspection.py new file mode 100644 index 0000000..3ff7037 --- /dev/null +++ b/src/mountainash_data/backends/ibis/_index_inspection.py @@ -0,0 +1,458 @@ +"""Index catalog inspection for generic and vendor-specific ibis backends.""" + +from __future__ import annotations + +import re +import typing as t + +from sqlglot import exp, parse_one + +from mountainash_data.backends.ibis._render import _sql_literal, quote_identifier +from mountainash_data.core.inspection import IndexInfo + + +def _normalize_flag(value: t.Any, field: str, *, allow_none: bool = False) -> bool | None: + if value is None and allow_none: + return None + if isinstance(value, bool): + return value + if isinstance(value, int) and not isinstance(value, bool) and value in (0, 1): + return bool(value) + raise RuntimeError(f"invalid {field} flag: {value!r}") + + +def _normalize_text(value: t.Any, field: str, *, allow_none: bool = True) -> str | None: + if value is None and allow_none: + return None + if not isinstance(value, str) or not value: + raise RuntimeError(f"invalid {field} text: {value!r}") + return value + +def _positional_values(row: t.Any) -> tuple[t.Any, ...]: + """Read Arrow rows by schema order, never by catalog alias.""" + if isinstance(row, dict): + return tuple(row.values()) + return tuple(row) + + +def _normalize_position(value: t.Any) -> int: + if not isinstance(value, int) or isinstance(value, bool) or value <= 0: + raise RuntimeError(f"invalid position: {value!r}") + return value + + +def _generic_list_indexes( + ibis_conn: t.Any, + table_name: str, + namespace: str | None, + list_sql_fn: t.Callable[[str, str | None], str], +) -> list[IndexInfo]: + """Run a ten-column catalog query and return strict typed index metadata.""" + result = ibis_conn.sql(list_sql_fn(table_name, namespace)) + if result is None: + return [] + + groups: dict[str, dict[str, t.Any]] = {} + for row_number, raw_row in enumerate( + result.to_pyarrow().to_pylist(), start=1 + ): + row = _positional_values(raw_row) + if len(row) != 10: + raise RuntimeError( + f"index catalog row {row_number} has {len(row)} columns; expected 10" + ) + ( + raw_name, + raw_unique, + raw_primary, + raw_valid, + raw_type, + raw_definition, + raw_col_name, + raw_col_expr, + raw_included, + raw_position, + ) = row + name = _normalize_text(raw_name, "index_name", allow_none=False) + unique = _normalize_flag(raw_unique, "is_unique") + is_primary = _normalize_flag(raw_primary, "is_primary") + is_valid = _normalize_flag(raw_valid, "is_valid", allow_none=True) + is_included = _normalize_flag(raw_included, "is_included") + index_type = _normalize_text(raw_type, "index_type") + definition = _normalize_text(raw_definition, "definition") + col_name = _normalize_text(raw_col_name, "col_name") + col_expr = _normalize_text(raw_col_expr, "col_expr") + position = _normalize_position(raw_position) + + metadata = (unique, is_primary, is_valid, index_type, definition) + group = groups.setdefault( + t.cast(str, name), + { + "metadata": metadata, + "positions": set(), + "keys": [], + "included": [], + }, + ) + if group["metadata"] != metadata: + raise RuntimeError(f"conflicting index metadata for {name!r}") + if position in group["positions"]: + raise RuntimeError(f"duplicate index position for {name!r}: {position}") + group["positions"].add(position) + + if is_included: + if col_expr is not None or not isinstance(col_name, str) or not col_name: + raise RuntimeError("invalid non-key index column") + group["included"].append((position, col_name)) + else: + value = col_expr if col_expr is not None else col_name + group["keys"].append( + (position, value if value is not None else "") + ) + + indexes: list[IndexInfo] = [] + for name, group in groups.items(): + if not group["keys"]: + raise RuntimeError(f"index {name!r} has no key positions") + unique, is_primary, is_valid, index_type, definition = group["metadata"] + indexes.append( + IndexInfo( + name=name, + unique=unique, + is_primary=is_primary, + columns=tuple(value for _, value in sorted(group["keys"])), + index_type=index_type.lower() if index_type is not None else None, + included_columns=tuple( + value for _, value in sorted(group["included"]) + ), + is_valid=is_valid, + definition=definition, + ) + ) + return sorted(indexes, key=lambda index: index.name) + + +def sqlite_get_list_indexes_sql( + table_name: str, namespace: str | None +) -> str: + scope = namespace or "main" + scope_literal = _sql_literal(scope) + scope_identifier = quote_identifier(scope, "sqlite") + return f""" +SELECT + CAST(l.name AS TEXT) AS index_name, + CAST(l."unique" AS INTEGER) AS is_unique, + CAST(l.origin = 'pk' AS INTEGER) AS is_primary, + CAST(NULL AS INTEGER) AS is_valid, + CAST('btree' AS TEXT) AS index_type, + CAST(m.sql AS TEXT) AS definition, + CAST(i.name AS TEXT) AS col_name, + CAST(NULL AS TEXT) AS col_expr, + CAST(0 AS INTEGER) AS is_included, + CAST(i.seqno + 1 AS INTEGER) AS position +FROM pragma_index_list({_sql_literal(table_name)}, {scope_literal}) l +JOIN pragma_index_xinfo(l.name, {scope_literal}) i +LEFT JOIN {scope_identifier}.sqlite_master m + ON m.type = 'index' AND m.name = l.name +WHERE i.key = 1 +""" + + +def postgres_get_list_indexes_sql( + table_name: str, namespace: str | None +) -> str: + schema = _sql_literal(namespace) if namespace else "current_schema()" + return f""" +SELECT + ic.relname AS index_name, + i.indisunique AS is_unique, + i.indisprimary AS is_primary, + i.indisvalid AS is_valid, + am.amname AS index_type, + pg_get_indexdef(i.indexrelid) AS definition, + CASE WHEN keypos.attnum = 0 THEN NULL ELSE attr.attname END AS col_name, + CASE WHEN keypos.attnum = 0 + THEN pg_get_indexdef(i.indexrelid, keypos.ord::integer, false) + ELSE NULL + END AS col_expr, + (keypos.ord > i.indnkeyatts) AS is_included, + keypos.ord AS position +FROM pg_index i +JOIN pg_class ic ON ic.oid = i.indexrelid +JOIN pg_class tc ON tc.oid = i.indrelid +JOIN pg_namespace n ON n.oid = tc.relnamespace +JOIN pg_am am ON am.oid = ic.relam +CROSS JOIN LATERAL unnest(i.indkey::smallint[]) WITH ORDINALITY + AS keypos(attnum, ord) +LEFT JOIN pg_attribute attr + ON attr.attrelid = i.indrelid AND attr.attnum = keypos.attnum +WHERE tc.relname = {_sql_literal(table_name)} + AND n.nspname = {schema} +""" + + +def mysql_get_list_indexes_sql( + table_name: str, namespace: str | None +) -> str: + schema = _sql_literal(namespace) if namespace else "DATABASE()" + return f""" +SELECT + INDEX_NAME AS index_name, + (NOT NON_UNIQUE) AS is_unique, + (INDEX_NAME = 'PRIMARY') AS is_primary, + CAST(NULL AS SIGNED) AS is_valid, + INDEX_TYPE AS index_type, + CAST(NULL AS CHAR) AS definition, + COLUMN_NAME AS col_name, + CAST(NULL AS CHAR) AS col_expr, + CAST(0 AS SIGNED) AS is_included, + SEQ_IN_INDEX AS position +FROM information_schema.STATISTICS +WHERE TABLE_NAME = {_sql_literal(table_name)} + AND TABLE_SCHEMA = {schema} +""" + + +def singlestore_get_list_indexes_sql( + table_name: str, namespace: str | None +) -> str: + return mysql_get_list_indexes_sql(table_name, namespace) + + +def mssql_get_list_indexes_sql( + table_name: str, namespace: str | None +) -> str: + target = f"{namespace}.{table_name}" if namespace else table_name + return f""" +SELECT + i.name AS index_name, + i.is_unique AS is_unique, + i.is_primary_key AS is_primary, + CASE WHEN i.is_disabled = 0 AND i.is_hypothetical = 0 + THEN CAST(1 AS INTEGER) ELSE CAST(0 AS INTEGER) END AS is_valid, + i.type_desc AS index_type, + i.filter_definition AS definition, + c.name AS col_name, + CAST(NULL AS NVARCHAR(MAX)) AS col_expr, + CASE + WHEN ic.key_ordinal > 0 THEN CAST(0 AS INTEGER) + WHEN ic.is_included_column = 1 OR ic.partition_ordinal > 0 + THEN CAST(1 AS INTEGER) + ELSE CAST(NULL AS INTEGER) + END AS is_included, + ic.index_column_id AS position +FROM sys.indexes i +JOIN sys.index_columns ic + ON ic.object_id = i.object_id AND ic.index_id = i.index_id +JOIN sys.columns c + ON c.object_id = ic.object_id AND c.column_id = ic.column_id +WHERE i.object_id = OBJECT_ID({_sql_literal(target)}) + AND i.index_id > 0 + AND i.type IN (1, 2) + AND ic.column_id > 0 +""" + + +def oracle_get_list_indexes_sql( + table_name: str, namespace: str | None +) -> str: + owner = _sql_literal(namespace) if namespace else "USER" + return f""" +SELECT + idx.index_name AS index_name, + CASE WHEN idx.uniqueness = 'UNIQUE' THEN 1 ELSE 0 END AS is_unique, + CASE WHEN con.constraint_name IS NOT NULL THEN 1 ELSE 0 END AS is_primary, + CASE + WHEN idx.partitioned <> 'NO' THEN NULL + WHEN idx.status = 'UNUSABLE' + OR idx.domidx_status = 'IDXTYP_INVLD' + OR idx.domidx_opstatus = 'FAILED' + OR idx.funcidx_status = 'DISABLED' + THEN 0 + WHEN idx.partitioned = 'NO' + AND idx.status = 'VALID' + AND ( + (idx.domidx_status IS NULL AND idx.domidx_opstatus IS NULL) + OR (idx.domidx_status = 'VALID' AND idx.domidx_opstatus = 'VALID') + ) + AND (idx.funcidx_status IS NULL OR idx.funcidx_status = 'ENABLED') + THEN 1 + ELSE NULL + END AS is_valid, + idx.index_type AS index_type, + CAST(NULL AS VARCHAR2(4000)) AS definition, + cols.column_name AS col_name, + exprs.column_expression AS col_expr, + 0 AS is_included, + cols.column_position AS position +FROM all_indexes idx +JOIN all_ind_columns cols + ON cols.index_owner = idx.owner + AND cols.index_name = idx.index_name +LEFT JOIN all_ind_expressions exprs + ON exprs.index_owner = idx.owner + AND exprs.index_name = idx.index_name + AND exprs.column_position = cols.column_position +LEFT JOIN all_constraints con + ON con.index_owner = idx.owner + AND con.index_name = idx.index_name + AND con.constraint_type = 'P' +WHERE idx.owner = {owner} + AND idx.table_name = {_sql_literal(table_name)} +""" + + +def _extract_duckdb_index_definition(definition: str) -> tuple[tuple[str, ...], str]: + """Extract ordered DuckDB index keys without parsing a display string.""" + try: + statement = parse_one(definition, read="duckdb") + except Exception as exc: + raise RuntimeError("invalid DuckDB CREATE INDEX definition") from exc + if not ( + isinstance(statement, exp.Create) + and statement.args.get("kind") == "INDEX" + and isinstance(statement.this, exp.Index) + ): + raise RuntimeError("DuckDB definition is not CREATE INDEX") + + params = statement.this.args.get("params") + columns = params.args.get("columns") if params is not None else None + if not columns: + raise RuntimeError("DuckDB CREATE INDEX has no key columns") + + keys: list[str] = [] + for ordered in columns: + expression = ordered.this if isinstance(ordered, exp.Ordered) else ordered + if isinstance(expression, exp.Column): + keys.append(expression.name) + else: + keys.append(expression.sql(dialect="duckdb")) + + using = params.args.get("using") if params is not None else None + index_type = using.name.lower() if isinstance(using, exp.Var) else "art" + return tuple(keys), index_type + + +def duckdb_get_indexes_sql(table_name: str, namespace: str | None) -> str: + schema = _sql_literal(namespace) if namespace else "current_schema()" + return f""" +SELECT index_oid, index_name, is_unique, is_primary, sql +FROM duckdb_indexes() +WHERE table_name = {_sql_literal(table_name)} + AND schema_name = {schema} + AND database_name = current_catalog() +""" + + +def duckdb_get_constraints_sql(table_name: str, namespace: str | None) -> str: + schema = _sql_literal(namespace) if namespace else "current_schema()" + return f""" +SELECT constraint_index, constraint_type, constraint_text, constraint_column_names +FROM duckdb_constraints() +WHERE table_name = {_sql_literal(table_name)} + AND schema_name = {schema} + AND database_name = current_catalog() + AND constraint_type IN ('PRIMARY KEY', 'UNIQUE', 'FOREIGN KEY') +""" + + +def duckdb_list_indexes_hook( + ibis_conn: t.Any, table_name: str, namespace: str | None +) -> list[IndexInfo]: + """List DuckDB explicit indexes and index-backed constraints.""" + explicit_result = ibis_conn.sql(duckdb_get_indexes_sql(table_name, namespace)) + constraint_result = ibis_conn.sql( + duckdb_get_constraints_sql(table_name, namespace) + ) + entries: list[tuple[str, str, int, IndexInfo]] = [] + + if explicit_result is not None: + for raw_row in explicit_result.to_pyarrow().to_pylist(): + row = _positional_values(raw_row) + if len(row) != 5: + raise RuntimeError("DuckDB index catalog row must have five columns") + index_oid, name, raw_unique, raw_primary, definition = row + if not isinstance(index_oid, int) or isinstance(index_oid, bool): + raise RuntimeError(f"invalid DuckDB index_oid: {index_oid!r}") + name = _normalize_text(name, "index_name", allow_none=False) + definition = _normalize_text( + definition, "definition", allow_none=False + ) + unique = _normalize_flag(raw_unique, "is_unique") + is_primary = _normalize_flag(raw_primary, "is_primary") + columns, index_type = _extract_duckdb_index_definition(definition) + entries.append( + ( + name, + "index", + index_oid, + IndexInfo( + name=name, + unique=unique, + is_primary=is_primary, + columns=columns, + index_type=index_type, + definition=definition, + metadata={"source_kind": "index", "index_oid": index_oid}, + ), + ) + ) + if constraint_result is not None: + for raw_row in constraint_result.to_pyarrow().to_pylist(): + row = _positional_values(raw_row) + if len(row) != 4: + raise RuntimeError( + "DuckDB constraint catalog row must have four columns" + ) + constraint_index, raw_type, definition, raw_columns = row + if not isinstance(constraint_index, int) or isinstance( + constraint_index, bool + ): + raise RuntimeError( + f"invalid DuckDB constraint_index: {constraint_index!r}" + ) + constraint_type = _normalize_text( + raw_type, "constraint_type", allow_none=False + ) + definition = _normalize_text( + definition, "definition", allow_none=False + ) + normalized_type = re.sub(r"\s+", "_", constraint_type.lower()) + if normalized_type not in {"primary_key", "unique", "foreign_key"}: + raise RuntimeError(f"unsupported DuckDB constraint type: {raw_type!r}") + if not isinstance(raw_columns, (list, tuple)) or not raw_columns: + raise RuntimeError("invalid DuckDB constraint columns") + columns = tuple( + _normalize_text(column, "constraint column", allow_none=False) + for column in raw_columns + ) + is_primary = normalized_type == "primary_key" + unique = normalized_type in {"primary_key", "unique"} + name = f"constraint_{normalized_type}_{table_name}_{constraint_index}" + entries.append( + ( + name, + "constraint", + constraint_index, + IndexInfo( + name=name, + unique=unique, + is_primary=is_primary, + columns=columns, + index_type="art", + definition=definition, + metadata={ + "source_kind": "constraint", + "constraint_type": normalized_type, + "constraint_index": constraint_index, + }, + ), + ) + ) + + return [ + info + for _, _, _, info in sorted( + entries, key=lambda entry: (entry[0], entry[1], entry[2]) + ) + ] diff --git a/src/mountainash_data/backends/ibis/_render.py b/src/mountainash_data/backends/ibis/_render.py index 6738746..75d80ba 100644 --- a/src/mountainash_data/backends/ibis/_render.py +++ b/src/mountainash_data/backends/ibis/_render.py @@ -26,6 +26,11 @@ def quote_identifier(name: str, dialect: t.Any) -> str: return exp.to_identifier(name, quoted=True).sql(dialect=dialect) +def _sql_literal(value: str) -> str: + """Render ``value`` as an escaped SQL string literal.""" + return exp.Literal.string(value).sql() + + def qualified_name(parts: list[str], dialect: t.Any) -> str: """Quote each part and join with '.' (e.g. database.table).""" return ".".join(quote_identifier(p, dialect) for p in parts) diff --git a/src/mountainash_data/backends/ibis/backend.py b/src/mountainash_data/backends/ibis/backend.py index 10f918b..c27ab44 100644 --- a/src/mountainash_data/backends/ibis/backend.py +++ b/src/mountainash_data/backends/ibis/backend.py @@ -16,14 +16,21 @@ from mountainash_data.backends.ibis._adoption import ( apply_options, snapshot_options, restore_options, ) -from mountainash_data.backends.ibis.operations import _generic_add_columns, _generic_rename_table, _generic_upsert +from mountainash_data.backends.ibis.operations import ( + _generic_add_columns, + _generic_rename_table, + _generic_upsert, + _validate_simple_identifier, +) from mountainash_data.backends.ibis._index import ( _generic_create_index, _generic_drop_index, _generic_index_exists, ) +from mountainash_data.backends.ibis._index_inspection import _generic_list_indexes from mountainash_data.core.inspection import ( CatalogInfo, + IndexInfo, NamespaceInfo, TableInfo, ) @@ -923,15 +930,25 @@ def list_indexes( table_name: str, *, namespace: NamespaceLike = None, - ) -> list[dict]: - if self._spec.get_list_indexes_sql is None: - raise NotImplementedError( - f"Dialect {self.dialect!r} does not support list_indexes" - ) + ) -> list[IndexInfo]: conn = self._require_connected() - rendered = _render_ibis_namespace_single(Namespace.coerce(namespace), op="list_indexes") - list_sql = self._spec.get_list_indexes_sql(table_name, rendered) - result = conn._ibis_conn.sql(list_sql) - if result is None: - return [] - return result.to_pyarrow().to_pylist() + rendered = _render_ibis_namespace_single( + Namespace.coerce(namespace), op="list_indexes" + ) + _validate_simple_identifier(table_name, kind="table_name") + if rendered is not None: + _validate_simple_identifier(rendered, kind="namespace") + if self._spec.list_indexes_hook is not None: + return self._spec.list_indexes_hook( + conn._ibis_conn, table_name, rendered + ) + if self._spec.index_caps is not None: + list_sql = self._spec.get_list_indexes_sql + if list_sql is None: + raise RuntimeError("index capability lacks a list-index implementation") + return _generic_list_indexes( + conn._ibis_conn, table_name, rendered, list_sql + ) + raise NotImplementedError( + f"Dialect {self.dialect!r} does not support list_indexes" + ) diff --git a/src/mountainash_data/backends/ibis/dialects/_registry.py b/src/mountainash_data/backends/ibis/dialects/_registry.py index fc0c8ff..81b3dce 100644 --- a/src/mountainash_data/backends/ibis/dialects/_registry.py +++ b/src/mountainash_data/backends/ibis/dialects/_registry.py @@ -21,6 +21,7 @@ from dataclasses import dataclass, field import typing as t +from mountainash_data.core.inspection import IndexInfo class UpsertStyle(str, enum.Enum): ON_CONFLICT = "on_conflict" @@ -54,9 +55,9 @@ class IndexCapability: 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 +ListIndexesHook = t.Callable[[t.Any, str, t.Optional[str]], list[IndexInfo]] ConnectionBuilder = t.Callable[..., t.Any] # (**config) -> ibis backend connection UpsertHook = t.Callable[..., None] CreateIndexHook = t.Callable[..., None] @@ -88,6 +89,7 @@ class DialectSpec: connection_builder: t.Optional[ConnectionBuilder] = None get_index_exists_sql: t.Optional[GetIndexExistsSql] = None get_list_indexes_sql: t.Optional[GetListIndexesSql] = None + list_indexes_hook: t.Optional[ListIndexesHook] = None upsert_hook: t.Optional[UpsertHook] = None # None = upsert not supported (no hook + no style -> NotImplementedError). upsert_style: t.Optional[UpsertStyle] = None @@ -695,14 +697,19 @@ def _build_pyspark_connection(**config: t.Any) -> t.Any: # Import capability hook functions from operations module. # These are the per-dialect index SQL functions. No circular imports since -# operations.py does not import from _registry.py. +from mountainash_data.backends.ibis._index_inspection import ( # noqa: E402 + duckdb_list_indexes_hook, + mssql_get_list_indexes_sql, + mysql_get_list_indexes_sql, + oracle_get_list_indexes_sql, + postgres_get_list_indexes_sql, + singlestore_get_list_indexes_sql, + sqlite_get_list_indexes_sql, +) from mountainash_data.backends.ibis.operations import ( # noqa: E402 duckdb_get_index_exists_sql, - duckdb_get_list_indexes_sql, sqlite_get_index_exists_sql, - 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, @@ -773,7 +780,7 @@ def _duckdb_render_timezone(v: t.Any) -> str: connection_string_scheme="duckdb://", connection_builder=_build_duckdb_connection, get_index_exists_sql=duckdb_get_index_exists_sql, - get_list_indexes_sql=duckdb_get_list_indexes_sql, + list_indexes_hook=duckdb_list_indexes_hook, upsert_style=UpsertStyle.ON_CONFLICT, index_caps=IndexCapability( drop_scope=DropScope.SCHEMA_GLOBAL, partial=False, @@ -791,7 +798,7 @@ def _duckdb_render_timezone(v: t.Any) -> str: connection_string_scheme="duckdb://md:", connection_builder=_build_motherduck_connection, get_index_exists_sql=motherduck_get_index_exists_sql, - get_list_indexes_sql=motherduck_get_list_indexes_sql, + list_indexes_hook=duckdb_list_indexes_hook, upsert_style=UpsertStyle.ON_CONFLICT, index_caps=IndexCapability( drop_scope=DropScope.SCHEMA_GLOBAL, partial=False, @@ -809,6 +816,7 @@ def _duckdb_render_timezone(v: t.Any) -> str: connection_string_scheme="postgres://", connection_builder=_build_postgres_connection, upsert_style=UpsertStyle.ON_CONFLICT, + get_list_indexes_sql=postgres_get_list_indexes_sql, get_index_exists_sql=postgres_get_index_exists_sql, index_caps=IndexCapability( drop_scope=DropScope.SCHEMA_GLOBAL, partial=True, @@ -826,6 +834,7 @@ def _duckdb_render_timezone(v: t.Any) -> str: connection_string_scheme="mysql://", connection_builder=_build_mysql_connection, upsert_style=UpsertStyle.ON_DUPLICATE_KEY, + get_list_indexes_sql=mysql_get_list_indexes_sql, get_index_exists_sql=mysql_get_index_exists_sql, index_caps=IndexCapability( drop_scope=DropScope.TABLE_SCOPED, partial=False, @@ -841,6 +850,7 @@ def _duckdb_render_timezone(v: t.Any) -> str: connection_string_scheme="mssql://", connection_builder=_build_mssql_connection, upsert_style=UpsertStyle.MERGE, + get_list_indexes_sql=mssql_get_list_indexes_sql, get_index_exists_sql=mssql_get_index_exists_sql, index_caps=IndexCapability( drop_scope=DropScope.TABLE_SCOPED, partial=True, @@ -856,6 +866,7 @@ def _duckdb_render_timezone(v: t.Any) -> str: connection_string_scheme="oracle://", connection_builder=_build_oracle_connection, upsert_style=UpsertStyle.MERGE, + get_list_indexes_sql=oracle_get_list_indexes_sql, get_index_exists_sql=oracle_get_index_exists_sql, index_caps=IndexCapability( drop_scope=DropScope.SCHEMA_GLOBAL, partial=False, @@ -925,6 +936,7 @@ def _duckdb_render_timezone(v: t.Any) -> str: connection_string_scheme="singlestoredb://", connection_builder=_build_singlestoredb_connection, upsert_style=UpsertStyle.ON_DUPLICATE_KEY, + get_list_indexes_sql=singlestore_get_list_indexes_sql, get_index_exists_sql=singlestore_get_index_exists_sql, index_caps=IndexCapability( drop_scope=DropScope.TABLE_SCOPED, partial=False, diff --git a/src/mountainash_data/backends/ibis/operations.py b/src/mountainash_data/backends/ibis/operations.py index 20763cd..966cc87 100644 --- a/src/mountainash_data/backends/ibis/operations.py +++ b/src/mountainash_data/backends/ibis/operations.py @@ -15,6 +15,7 @@ from sqlglot import exp from mountainash_data.backends.ibis._render import ( ConditionAliases, + _sql_literal, compile_condition, compiled_source, dialect_of, @@ -153,11 +154,6 @@ 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: @@ -239,42 +235,21 @@ def duckdb_get_index_exists_sql( table_name: str | None, namespace: str | None ) -> str: - """DuckDB uses duckdb_indexes() system function. `namespace` is a single- - level qualifier, which DuckDB's ibis backend (and this package's own - `database=` convention) treats as the SCHEMA, not the catalog — so it is - matched against duckdb_indexes()'s `schema_name` column, not - `database_name` (which holds the catalog, e.g. "memory").""" - where_clauses = [f"index_name = {_sql_literal(index_name)}"] + """DuckDB index existence scoped to schema and current catalog.""" + where_clauses = [ + f"index_name = {_sql_literal(index_name)}", + f"schema_name = {_sql_literal(namespace) if namespace else 'current_schema()'}", + "database_name = current_catalog()", + ] if table_name: where_clauses.append(f"table_name = {_sql_literal(table_name)}") - if namespace: - where_clauses.append(f"schema_name = {_sql_literal(namespace)}") + return ( + "SELECT COUNT(*) AS count FROM duckdb_indexes() " + f"WHERE {' AND '.join(where_clauses)}" + ) - where_sql = " AND ".join(where_clauses) - return f"SELECT COUNT(*) as count FROM duckdb_indexes() WHERE {where_sql}" -def duckdb_get_list_indexes_sql( - table_name: str, - namespace: str | None -) -> str: - """DuckDB uses duckdb_indexes() system function. See `namespace` note on - `duckdb_get_index_exists_sql` — matched against `schema_name`, not - `database_name`.""" - where_clauses = [f"table_name = '{table_name}'"] - if namespace: - where_clauses.append(f"schema_name = '{namespace}'") - - where_sql = " AND ".join(where_clauses) - return f""" - SELECT - index_name as name, - sql as definition, - is_unique as unique - FROM duckdb_indexes() - WHERE {where_sql} - """ - # --- SQLite --- @@ -283,31 +258,16 @@ def sqlite_get_index_exists_sql( table_name: str | None, namespace: str | None ) -> str: - """SQLite uses the sqlite_master system table. `namespace` is unused (no - cross-database queries).""" + """SQLite's ``namespace`` selects the attached database schema.""" where_clauses = ["type = 'index'", f"name = {_sql_literal(index_name)}"] if 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}" + scope = namespace or "main" + master = f"{quote_identifier(scope, 'sqlite')}.sqlite_master" + return f"SELECT COUNT(*) AS count FROM {master} WHERE {where_sql}" -def sqlite_get_list_indexes_sql( - table_name: str, - namespace: str | None -) -> str: - """SQLite uses sqlite_master system table. - Note: namespace parameter is not used as SQLite doesn't support cross-database queries. - """ - return f""" - SELECT - name, - sql as definition, - CASE WHEN sql LIKE '%UNIQUE%' THEN 1 ELSE 0 END as "unique" - FROM sqlite_master - WHERE type = 'index' - AND tbl_name = '{table_name}' - """ # --- MotherDuck --- @@ -317,39 +277,20 @@ def motherduck_get_index_exists_sql( table_name: str | None, namespace: str | None ) -> str: - """MotherDuck uses DuckDB's duckdb_indexes() system function. See - `namespace` note on `duckdb_get_index_exists_sql` — matched against - `schema_name`, not `database_name`.""" - where_clauses = [f"index_name = {_sql_literal(index_name)}"] + """MotherDuck index existence scoped to schema and current catalog.""" + where_clauses = [ + f"index_name = {_sql_literal(index_name)}", + f"schema_name = {_sql_literal(namespace) if namespace else 'current_schema()'}", + "database_name = current_catalog()", + ] if table_name: where_clauses.append(f"table_name = {_sql_literal(table_name)}") - if namespace: - where_clauses.append(f"schema_name = {_sql_literal(namespace)}") - - where_sql = " AND ".join(where_clauses) - return f"SELECT COUNT(*) as count FROM duckdb_indexes() WHERE {where_sql}" - + return ( + "SELECT COUNT(*) AS count FROM duckdb_indexes() " + f"WHERE {' AND '.join(where_clauses)}" + ) -def motherduck_get_list_indexes_sql( - table_name: str, - namespace: str | None -) -> str: - """MotherDuck uses DuckDB's duckdb_indexes() system function. See - `namespace` note on `duckdb_get_index_exists_sql` — matched against - `schema_name`, not `database_name`.""" - where_clauses = [f"table_name = '{table_name}'"] - if namespace: - where_clauses.append(f"schema_name = '{namespace}'") - where_sql = " AND ".join(where_clauses) - return f""" - SELECT - index_name as name, - sql as definition, - is_unique as unique - FROM duckdb_indexes() - WHERE {where_sql} - """ # --- PostgreSQL --- diff --git a/src/mountainash_data/core/inspection.py b/src/mountainash_data/core/inspection.py index f9b8f65..3535430 100644 --- a/src/mountainash_data/core/inspection.py +++ b/src/mountainash_data/core/inspection.py @@ -64,3 +64,18 @@ class CatalogInfo: name: str namespaces: t.Sequence[NamespaceInfo] metadata: t.Mapping[str, t.Any] = field(default_factory=dict) + + +@dataclass(frozen=True) +class IndexInfo: + """Physical metadata for a single index.""" + + name: str + unique: bool + is_primary: bool + columns: t.Sequence[str] + index_type: t.Optional[str] = None + included_columns: t.Sequence[str] = () + is_valid: t.Optional[bool] = None + definition: t.Optional[str] = None + metadata: t.Mapping[str, t.Any] = field(default_factory=dict) diff --git a/tests/test_integration/test_end_to_end_workflows.py b/tests/test_integration/test_end_to_end_workflows.py index 48cb1cb..8ce8ec4 100644 --- a/tests/test_integration/test_end_to_end_workflows.py +++ b/tests/test_integration/test_end_to_end_workflows.py @@ -103,9 +103,8 @@ def test_index_lifecycle(self): backend.create_index("t", ["name"], index_name="idx_name") assert backend.index_exists("idx_name") is True - indexes = backend.list_indexes("t") - assert any(idx.get("name") == "idx_name" for idx in indexes) + assert any(idx.name == "idx_name" for idx in indexes) backend.drop_index("idx_name") assert backend.index_exists("idx_name") is False diff --git a/tests/test_unit/backends/ibis/test_backend.py b/tests/test_unit/backends/ibis/test_backend.py index 8ea25ca..a29a28a 100644 --- a/tests/test_unit/backends/ibis/test_backend.py +++ b/tests/test_unit/backends/ibis/test_backend.py @@ -442,10 +442,10 @@ def test_index_exists(): assert backend.index_exists("no_such_idx") is False -def test_list_indexes(): - """list_indexes() must return one row-dict per index with name/definition/ - unique keys, correctly typed (guards the DEBT-1 refactor away from - ma.relation().to_dicts() onto native ibis .to_pyarrow().to_pylist()).""" +def test_list_indexes_returns_typed_index_info(): + """list_indexes() returns typed physical index metadata.""" + from mountainash_data import IndexInfo + with IbisBackend(dialect="sqlite", database=":memory:") as backend: backend.create_table("t", {"id": [1], "name": ["a"]}) backend.create_index("t", ["id"], index_name="idx_id", unique=True) @@ -453,12 +453,42 @@ def test_list_indexes(): assert isinstance(indexes, list) assert len(indexes) == 1 row = indexes[0] - assert set(row.keys()) == {"name", "definition", "unique"} - assert row["name"] == "idx_id" - assert "idx_id" in row["definition"] - assert row["unique"] == 1 + assert isinstance(row, IndexInfo) + assert row.name == "idx_id" + assert row.columns == ("id",) + assert row.definition is not None and "idx_id" in row.definition + assert row.unique is True + + +def test_list_indexes_validates_before_custom_hook(): + from dataclasses import replace + from mountainash_data import IndexInfo + from mountainash_data.backends.ibis.dialects._registry import DIALECTS + calls = [] + + def hook(conn, table_name, namespace): + calls.append((table_name, namespace)) + return [IndexInfo("ix", False, False, ("id",))] + + backend = IbisBackend(dialect="sqlite", database=":memory:") + backend._spec = replace( + DIALECTS["sqlite"], + get_list_indexes_sql=None, + list_indexes_hook=hook, + ) + backend.connect() + try: + with pytest.raises(ValueError, match="simple identifier"): + backend.list_indexes("bad; name") + assert calls == [] + assert backend.list_indexes("t") == [ + IndexInfo("ix", False, False, ("id",)) + ] + assert calls == [("t", None)] + finally: + backend.close() def test_upsert_duckdb(): """upsert() must work on DuckDB via hook.""" with IbisBackend(dialect="duckdb", database=":memory:") as backend: diff --git a/tests/test_unit/backends/ibis/test_index_capability.py b/tests/test_unit/backends/ibis/test_index_capability.py index dae2b50..a90a787 100644 --- a/tests/test_unit/backends/ibis/test_index_capability.py +++ b/tests/test_unit/backends/ibis/test_index_capability.py @@ -4,6 +4,8 @@ import pytest +from mountainash_data import IndexInfo +from mountainash_data.backends.ibis._render import _sql_literal from mountainash_data.backends.ibis.dialects._registry import ( DialectSpec, DropScope, @@ -84,10 +86,39 @@ def test_invariant_caps_implies_exists_sql(name): 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).""" + """Every supported index dialect has exactly one list-index route.""" 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" - ) + assert (spec.get_list_indexes_sql is None) != ( + spec.list_indexes_hook is None + ), f"{name}: index_caps must have one list-index route" + + +def test_list_index_route_matrix(): + generic = {"sqlite", "postgres", "mysql", "mssql", "oracle", "singlestoredb"} + hook = {"duckdb", "motherduck"} + for name in generic: + assert DIALECTS[name].get_list_indexes_sql is not None + assert DIALECTS[name].list_indexes_hook is None + for name in hook: + assert DIALECTS[name].get_list_indexes_sql is None + assert DIALECTS[name].list_indexes_hook is not None + + + +def test_indexinfo_is_frozen_and_preserves_ordered_columns(): + info = IndexInfo( + name="ix", + unique=False, + is_primary=True, + columns=("id",), + included_columns=("partition_id",), + metadata={"source_kind": "index"}, + ) + assert info.columns == ("id",) + assert info.included_columns == ("partition_id",) + assert info.unique is False and info.is_primary is True + + +def test_sql_literal_is_neutral_shared_helper(): + assert _sql_literal("x'y") == "'x''y'" \ No newline at end of file diff --git a/tests/test_unit/backends/ibis/test_index_ops.py b/tests/test_unit/backends/ibis/test_index_ops.py index 3204cd0..267aac8 100644 --- a/tests/test_unit/backends/ibis/test_index_ops.py +++ b/tests/test_unit/backends/ibis/test_index_ops.py @@ -9,6 +9,124 @@ _generic_drop_index, _generic_index_exists, ) +from mountainash_data.backends.ibis._index_inspection import _generic_list_indexes +from mountainash_data.backends.ibis.dialects._registry import DIALECTS +from mountainash_data import IndexInfo + + +class _FakeArrow: + def __init__(self, rows): + self._rows = rows + + def to_pylist(self): + return self._rows + + +class _FakeSqlResult: + def __init__(self, rows): + self._rows = rows + + def to_pyarrow(self): + return _FakeArrow(self._rows) + + +class _FakeConnection: + def __init__(self, rows): + self.rows = rows + self.sql_text = None + + def sql(self, sql): + self.sql_text = sql + return _FakeSqlResult(self.rows) + + +def _valid_index_rows(): + metadata = ("ix", True, False, None, "BTREE", "CREATE INDEX ix") + return [ + [*metadata, "covered", None, True, 3], + [*metadata, None, None, False, 2], + [*metadata, "a", None, False, 1], + ] + + +def test_generic_list_indexes_groups_positional_arrow_rows(): + con = _FakeConnection(list(reversed(_valid_index_rows()))) + + result = _generic_list_indexes(con, "t", None, lambda table, namespace: "SQL") + + assert result == [ + IndexInfo( + name="ix", + unique=True, + is_primary=False, + columns=("a", ""), + included_columns=("covered",), + index_type="btree", + is_valid=None, + definition="CREATE INDEX ix", + ) + ] + assert con.sql_text == "SQL" + + +def test_generic_list_indexes_accepts_arrow_mapping_rows(): + rows = [dict(enumerate(row)) for row in _valid_index_rows()] + + result = _generic_list_indexes( + _FakeConnection(rows), "t", None, lambda table, namespace: "SQL" + ) + + assert result[0].name == "ix" + assert result[0].columns == ("a", "") + assert result[0].included_columns == ("covered",) + + +@pytest.mark.parametrize( + ("mutator", "message"), + [ + (lambda rows: rows[0].__setitem__(1, "1"), "flag"), + (lambda rows: rows[0].__setitem__(8, None), "flag"), + (lambda rows: rows[0].__setitem__(9, True), "position"), + (lambda rows: rows[0].__setitem__(9, 0), "position"), + (lambda rows: rows.append(rows[0]), "position"), + (lambda rows: rows[0].__setitem__(0, ""), "index_name"), + (lambda rows: rows[0].__setitem__(6, ""), "col_name"), + (lambda rows: rows[0].__setitem__(7, "expr"), "non-key"), + ], +) +def test_generic_list_indexes_rejects_malformed_rows(mutator, message): + rows = _valid_index_rows() + mutator(rows) + + with pytest.raises(RuntimeError, match=message): + _generic_list_indexes(_FakeConnection(rows), "t", None, lambda *_: "SQL") + + +def test_generic_list_indexes_rejects_missing_key_and_conflicting_metadata(): + rows = _valid_index_rows() + rows = [row for row in rows if row[8]] + with pytest.raises(RuntimeError, match="key"): + _generic_list_indexes(_FakeConnection(rows), "t", None, lambda *_: "SQL") + + rows = _valid_index_rows() + conflicting = list(rows[1]) + conflicting[1] = False + rows.append(tuple(conflicting)) + with pytest.raises(RuntimeError, match="metadata"): + _generic_list_indexes(_FakeConnection(rows), "t", None, lambda *_: "SQL") + + +def test_generic_list_indexes_accepts_nonunique_primary_index(): + rows = _valid_index_rows() + rows = [tuple(False if i == 1 else True if i == 2 else value for i, value in enumerate(row)) + for row in rows] + + result = _generic_list_indexes( + _FakeConnection(rows), "t", None, lambda *_: "SQL" + ) + + assert result[0].unique is False + assert result[0].is_primary is True from mountainash_data.backends.ibis.dialects._registry import DIALECTS _SQLITE = DIALECTS["sqlite"].index_caps @@ -212,3 +330,80 @@ def test_create_index_rejects_catalog_qualified_namespace(): backend.create_table("t", {"id": [1]}) with pytest.raises(ValueError, match="does not support catalog-qualified"): backend.create_index("t", ["id"], namespace=Namespace(catalog="wh", path=("s",))) + + +from mountainash_data.backends.ibis._index_inspection import ( # noqa: E402 + _extract_duckdb_index_definition, + duckdb_get_constraints_sql, + duckdb_get_indexes_sql, + duckdb_list_indexes_hook, +) + + +@pytest.mark.parametrize( + ("definition", "columns", "index_type"), + [ + ("CREATE INDEX ix ON t (a, b)", ("a", "b"), "art"), + ("CREATE INDEX ix ON t ((coalesce(a, b)))", ("(COALESCE(a, b))",), "art"), + ('CREATE INDEX ix ON t ("a, b")', ("a, b",), "art"), + ("CREATE INDEX ix ON t USING HNSW (a)", ("a",), "hnsw"), + ], +) +def test_duckdb_index_definition_extracts_structured_keys( + definition, columns, index_type +): + result = _extract_duckdb_index_definition(definition) + assert result == (columns, index_type) + + +def test_duckdb_index_definition_rejects_non_create_ast(): + with pytest.raises(RuntimeError, match="CREATE INDEX"): + _extract_duckdb_index_definition("CREATE TABLE t (id INTEGER)") + + +def test_duckdb_source_queries_are_schema_and_catalog_scoped(): + indexes_sql = duckdb_get_indexes_sql("t", "s") + constraints_sql = duckdb_get_constraints_sql("t", None) + assert "schema_name = 's'" in indexes_sql + assert "schema_name = current_schema()" in constraints_sql + assert "database_name = current_catalog()" in indexes_sql + assert "database_name = current_catalog()" in constraints_sql + assert "expressions" not in indexes_sql + assert "constraint_name" not in constraints_sql + + +class _DuckDbHookConnection: + def __init__(self, index_rows, constraint_rows): + self._results = [_FakeSqlResult(index_rows), _FakeSqlResult(constraint_rows)] + self.sql_texts = [] + + def sql(self, sql): + self.sql_texts.append(sql) + return self._results.pop(0) + + +def test_duckdb_hook_keeps_constraints_and_explicit_indexes_distinct(): + con = _DuckDbHookConnection( + [ + (7, "constraint_unique_t_2", True, False, "CREATE INDEX constraint_unique_t_2 ON t (a)"), + (8, "ix", True, False, "CREATE INDEX ix ON t (a, b)"), + ], + [ + (2, "UNIQUE", "UNIQUE (a)", ["a"]), + (3, "PRIMARY KEY", "PRIMARY KEY (id)", ["id"]), + (4, "FOREIGN KEY", "FOREIGN KEY (parent_id)", ["parent_id"]), + ], + ) + + result = duckdb_list_indexes_hook(con, "t", "s") + + assert [(item.name, item.metadata["source_kind"]) for item in result] == [ + ("constraint_foreign_key_t_4", "constraint"), + ("constraint_primary_key_t_3", "constraint"), + ("constraint_unique_t_2", "constraint"), + ("constraint_unique_t_2", "index"), + ("ix", "index"), + ] + assert result[0].unique is False + assert result[1].unique is True and result[1].is_primary is True + assert result[2].metadata["constraint_type"] == "unique" diff --git a/tests/test_unit/backends/ibis/test_index_render.py b/tests/test_unit/backends/ibis/test_index_render.py index e3c3c7e..6ed2383 100644 --- a/tests/test_unit/backends/ibis/test_index_render.py +++ b/tests/test_unit/backends/ibis/test_index_render.py @@ -192,3 +192,93 @@ def test_injection_payload_is_escaped_not_broken(self, fn): # escaped literal (doubled quote), not interpolated raw. sql = fn("x'; DROP TABLE t; --", "t", None) assert "''" in sql + + +from mountainash_data.backends.ibis._index_inspection import ( # noqa: E402 + mssql_get_list_indexes_sql, + mysql_get_list_indexes_sql, + oracle_get_list_indexes_sql, + postgres_get_list_indexes_sql, + singlestore_get_list_indexes_sql, + sqlite_get_list_indexes_sql, +) + + +@pytest.mark.parametrize( + "builder", + [ + sqlite_get_list_indexes_sql, + postgres_get_list_indexes_sql, + mysql_get_list_indexes_sql, + mssql_get_list_indexes_sql, + oracle_get_list_indexes_sql, + singlestore_get_list_indexes_sql, + ], +) +def test_generic_list_index_builders_expose_ten_column_contract(builder): + sql = builder("x'y", "aux") + aliases = [ + "index_name", + "is_unique", + "is_primary", + "is_valid", + "index_type", + "definition", + "col_name", + "col_expr", + "is_included", + "position", + ] + assert all(alias in sql for alias in aliases) + assert "x''y" in sql + + +def test_generic_list_index_builder_vendor_shapes(): + sqlite_sql = sqlite_get_list_indexes_sql("t", "aux") + postgres_sql = postgres_get_list_indexes_sql("t", "public") + mysql_sql = mysql_get_list_indexes_sql("t", "db") + mssql_sql = mssql_get_list_indexes_sql("t", "dbo") + oracle_sql = oracle_get_list_indexes_sql("t", None) + + assert "pragma_index_list('t', 'aux')" in sqlite_sql + assert "pragma_index_xinfo(l.name, 'aux')" in sqlite_sql + assert '"aux".sqlite_master' in sqlite_sql + assert "keypos.ord::integer" in postgres_sql + assert "unnest(i.indkey::smallint[]) WITH ORDINALITY" in postgres_sql + assert "INDEX_TYPE" in mysql_sql and "EXPRESSION" not in mysql_sql + assert "i.type IN (1, 2)" in mssql_sql + assert "ic.column_id > 0" in mssql_sql + assert "ic.partition_ordinal > 0" in mssql_sql + assert "exprs.column_expression" in oracle_sql + assert "COALESCE" not in oracle_sql + + +def test_oracle_list_index_builder_scopes_owner(): + sql = oracle_get_list_indexes_sql("t", "OTHER_OWNER") + + assert "FROM all_indexes idx" in sql + assert "idx.owner = 'OTHER_OWNER'" in sql + assert "cols.index_owner = idx.owner" in sql + assert "con.index_owner = idx.owner" in sql + + +def test_sqlite_attachment_index_ddl_qualifies_index_not_table(): + import ibis + + from mountainash_data.backends.ibis._index import _generic_create_index + from mountainash_data.backends.ibis.dialects._registry import DIALECTS + + con = ibis.sqlite.connect() + con.raw_sql("ATTACH ':memory:' AS aux") + con.raw_sql("CREATE TABLE aux.t (id INTEGER)") + caps = DIALECTS["sqlite"].index_caps + _generic_create_index( + con, + "t", + ["id"], + index_name="ix", + namespace="aux", + caps=caps, + exists_sql_fn=DIALECTS["sqlite"].get_index_exists_sql, + ) + assert con.raw_sql("SELECT name FROM aux.sqlite_master WHERE type = 'index'").fetchall() == [("ix",)]