Skip to content

Commit 0630bb7

Browse files
committed
feat: add partial index (WHERE clause) support to Alembic BM25 ops and autogenerate comparator
1 parent 006309e commit 0630bb7

4 files changed

Lines changed: 534 additions & 23 deletions

File tree

paradedb/sqlalchemy/alembic.py

Lines changed: 58 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -33,12 +33,14 @@ def __init__(
3333
key_field: str,
3434
*,
3535
table_schema: str | None = None,
36+
where: str | None = None,
3637
) -> None:
3738
self.index_name = index_name
3839
self.table_name = table_name
3940
self.expressions = expressions
4041
self.key_field = key_field
4142
self.table_schema = table_schema
43+
self.where = where
4244

4345
@classmethod
4446
def create_bm25_index(
@@ -50,6 +52,7 @@ def create_bm25_index(
5052
*,
5153
key_field: str,
5254
table_schema: str | None = None,
55+
where: str | None = None,
5356
) -> MigrateOperation:
5457
return operations.invoke(
5558
cls(
@@ -58,6 +61,7 @@ def create_bm25_index(
5861
expressions,
5962
key_field,
6063
table_schema=table_schema,
64+
where=where,
6165
)
6266
)
6367

@@ -70,6 +74,8 @@ def _create_bm25_index_impl(operations: Operations, operation: CreateBM25IndexOp
7074
f"ON {_quote_qualified(operation.table_schema, operation.table_name)} "
7175
f"USING bm25 ({expressions_sql}) WITH (key_field={_quote_literal(operation.key_field)})"
7276
)
77+
if operation.where is not None:
78+
sql += f" WHERE {operation.where}"
7379
operations.execute(sql)
7480

7581

@@ -83,6 +89,8 @@ def _render_create_bm25_index_op(autogen_context, op: CreateBM25IndexOp) -> str:
8389
]
8490
if op.table_schema is not None:
8591
parts.append(f"table_schema={op.table_schema!r}")
92+
if op.where is not None:
93+
parts.append(f"where={op.where!r}")
8694
return f"op.create_bm25_index({', '.join(parts)})"
8795

8896

@@ -164,8 +172,8 @@ def _autogen_bm25_meta_indexes(
164172

165173

166174
def _autogen_bm25_db_indexes(conn, effective_schemas: set[str]) -> dict[tuple[str, str], dict]:
167-
"""Return {(schema, index_name): {table_name, expressions, key_field}} from pg_indexes."""
168-
from .indexing import _extract_bm25_field_list, _extract_key_field
175+
"""Return {(schema, index_name): {table_name, expressions, key_field, where}} from pg_indexes."""
176+
from .indexing import _extract_bm25_field_list, _extract_key_field, _extract_where_clause
169177

170178
result: dict[tuple[str, str], dict] = {}
171179
for schema in effective_schemas:
@@ -187,6 +195,7 @@ def _autogen_bm25_db_indexes(conn, effective_schemas: set[str]) -> dict[tuple[st
187195
"table_name": row.tablename,
188196
"expressions": raw_fields,
189197
"key_field": _extract_key_field(row.indexdef) or "",
198+
"where": _extract_where_clause(row.indexdef),
190199
}
191200
return result
192201

@@ -264,6 +273,35 @@ def _normalized_expression_list(expressions: list[str]) -> list[str]:
264273
return [_normalize_bm25_expression(expr) for expr in expressions]
265274

266275

276+
def _normalize_where(clause: str | None) -> str | None:
277+
"""Normalize a WHERE clause string for comparison.
278+
279+
Strips whitespace, removes double quotes, and lowercases to reduce
280+
false-positive drift detection between PostgreSQL's normalized form
281+
and the SQLAlchemy-compiled form.
282+
"""
283+
if clause is None:
284+
return None
285+
normalized = " ".join(clause.split())
286+
normalized = normalized.replace('"', "")
287+
return normalized.lower()
288+
289+
290+
def _render_where_from_index(index) -> str | None:
291+
"""Compile the ``postgresql_where`` clause from a SQLAlchemy Index to SQL text."""
292+
where_clause = index.dialect_options["postgresql"].get("where")
293+
if where_clause is None:
294+
return None
295+
if isinstance(where_clause, ClauseElement):
296+
return str(
297+
where_clause.compile(
298+
dialect=postgresql.dialect(),
299+
compile_kwargs={"literal_binds": True},
300+
)
301+
)
302+
return str(where_clause)
303+
304+
267305
def _suppress_standard_bm25_ops(upgrade_ops, bm25_names: set[str]) -> None:
268306
"""Remove any standard Alembic CreateIndexOp/DropIndexOp for BM25 indexes."""
269307
from alembic.operations.ops import CreateIndexOp, DropIndexOp, ModifyTableOps
@@ -317,38 +355,36 @@ def _compare_bm25_indexes(autogen_context, upgrade_ops, schemas) -> PriorityDisp
317355
upgrade_ops.ops.append(DropBM25IndexOp(index_name=key[1], if_exists=True, schema=key[0]))
318356

319357
# Emit create ops for indexes present in MetaData but absent from DB.
320-
# Also re-create indexes whose expression list or key_field differs from the DB.
358+
# Also re-create indexes whose expression list, key_field, or WHERE clause differs from the DB.
321359
for key, index in meta_bm25.items():
322360
with_opts = index.dialect_options["postgresql"].get("with") or {}
323361
key_field = with_opts.get("key_field", "")
324362
expressions = [
325363
_strip_relation_qualifiers(_render_bm25_expression(expr), index.table.name)
326364
for expr in index.expressions
327365
]
366+
meta_where = _render_where_from_index(index)
367+
368+
def _make_create_op():
369+
return CreateBM25IndexOp(
370+
index_name=index.name,
371+
table_name=index.table.name,
372+
expressions=expressions,
373+
key_field=key_field,
374+
table_schema=key[0],
375+
where=meta_where,
376+
)
328377

329378
if key not in db_bm25:
330-
upgrade_ops.ops.append(
331-
CreateBM25IndexOp(
332-
index_name=index.name,
333-
table_name=index.table.name,
334-
expressions=expressions,
335-
key_field=key_field,
336-
table_schema=key[0],
337-
)
338-
)
379+
upgrade_ops.ops.append(_make_create_op())
339380
else:
340381
db = db_bm25[key]
341-
if _normalized_expression_list(db["expressions"]) != _normalized_expression_list(expressions) or db["key_field"] != key_field:
382+
expressions_changed = _normalized_expression_list(db["expressions"]) != _normalized_expression_list(expressions)
383+
key_field_changed = db["key_field"] != key_field
384+
where_changed = _normalize_where(db.get("where")) != _normalize_where(meta_where)
385+
if expressions_changed or key_field_changed or where_changed:
342386
# Index configuration changed: drop the old one, create the new one.
343387
upgrade_ops.ops.append(DropBM25IndexOp(index_name=key[1], if_exists=True, schema=key[0]))
344-
upgrade_ops.ops.append(
345-
CreateBM25IndexOp(
346-
index_name=index.name,
347-
table_name=index.table.name,
348-
expressions=expressions,
349-
key_field=key_field,
350-
table_schema=key[0],
351-
)
352-
)
388+
upgrade_ops.ops.append(_make_create_op())
353389

354390
return PriorityDispatchResult.CONTINUE

paradedb/sqlalchemy/indexing.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -578,6 +578,21 @@ def _extract_key_field(indexdef: str) -> str | None:
578578
return None
579579

580580

581+
_WHERE_CLAUSE_RE = re.compile(r"\bWHERE\s*\((.+)\)\s*$", re.IGNORECASE)
582+
583+
584+
def _extract_where_clause(indexdef: str) -> str | None:
585+
"""Extract the WHERE predicate from a ``pg_indexes.indexdef`` string.
586+
587+
PostgreSQL normalizes partial index predicates as ``WHERE (<condition>)``.
588+
Returns the inner condition text or ``None`` if not present.
589+
"""
590+
match = _WHERE_CLAUSE_RE.search(indexdef)
591+
if match:
592+
return match.group(1).strip()
593+
return None
594+
595+
581596
def _extract_alias(index_expr: str) -> str | None:
582597
match = _ALIAS_RE.search(index_expr)
583598
if match:

0 commit comments

Comments
 (0)