Skip to content

Commit 80bbad1

Browse files
committed
Fix BM25 partial predicate autogenerate drift
1 parent 0630bb7 commit 80bbad1

2 files changed

Lines changed: 160 additions & 20 deletions

File tree

paradedb/sqlalchemy/alembic.py

Lines changed: 73 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -273,18 +273,76 @@ def _normalized_expression_list(expressions: list[str]) -> list[str]:
273273
return [_normalize_bm25_expression(expr) for expr in expressions]
274274

275275

276+
def _normalize_sql_for_compare(expr: str) -> str:
277+
"""Normalize SQL text outside string literals for stable comparisons.
278+
279+
This collapses whitespace, removes identifier quotes, and lowercases
280+
non-literal SQL text while preserving the exact contents of single-quoted
281+
string literals.
282+
"""
283+
out: list[str] = []
284+
in_single = False
285+
in_double = False
286+
pending_space = False
287+
i = 0
288+
289+
while i < len(expr):
290+
ch = expr[i]
291+
292+
if ch == "'" and not in_double:
293+
if pending_space and out and out[-1] != " ":
294+
out.append(" ")
295+
pending_space = False
296+
out.append(ch)
297+
if in_single and i + 1 < len(expr) and expr[i + 1] == "'":
298+
out.append("'")
299+
i += 2
300+
continue
301+
in_single = not in_single
302+
i += 1
303+
continue
304+
305+
if ch == '"' and not in_single:
306+
pending_space = False
307+
in_double = not in_double
308+
i += 1
309+
continue
310+
311+
if in_single:
312+
out.append(ch)
313+
i += 1
314+
continue
315+
316+
if ch.isspace():
317+
pending_space = True
318+
i += 1
319+
continue
320+
321+
if pending_space and out and out[-1] != " ":
322+
out.append(" ")
323+
pending_space = False
324+
325+
if in_double:
326+
out.append(ch)
327+
else:
328+
out.append(ch.lower())
329+
i += 1
330+
331+
return "".join(out).strip()
332+
333+
276334
def _normalize_where(clause: str | None) -> str | None:
277335
"""Normalize a WHERE clause string for comparison.
278336
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.
337+
Reduces false-positive drift between PostgreSQL's normalized form and the
338+
SQLAlchemy-compiled form while preserving the exact contents of
339+
single-quoted string literals.
282340
"""
283341
if clause is None:
284342
return None
285-
normalized = " ".join(clause.split())
286-
normalized = normalized.replace('"', "")
287-
return normalized.lower()
343+
normalized = _normalize_sql_for_compare(clause)
344+
normalized = normalized.replace("::text", "")
345+
return _strip_non_pdb_qualifiers(normalized)
288346

289347

290348
def _render_where_from_index(index) -> str | None:
@@ -293,13 +351,16 @@ def _render_where_from_index(index) -> str | None:
293351
if where_clause is None:
294352
return None
295353
if isinstance(where_clause, ClauseElement):
296-
return str(
297-
where_clause.compile(
298-
dialect=postgresql.dialect(),
299-
compile_kwargs={"literal_binds": True},
300-
)
354+
return _strip_relation_qualifiers(
355+
str(
356+
where_clause.compile(
357+
dialect=postgresql.dialect(),
358+
compile_kwargs={"literal_binds": True},
359+
)
360+
),
361+
index.table.name,
301362
)
302-
return str(where_clause)
363+
return _strip_relation_qualifiers(str(where_clause), index.table.name)
303364

304365

305366
def _suppress_standard_bm25_ops(upgrade_ops, bm25_names: set[str]) -> None:

tests/integration/test_alembic_integration.py

Lines changed: 87 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -534,7 +534,85 @@ def test_autogenerate_detects_changed_partial_predicate(engine):
534534

535535

536536
# ---------------------------------------------------------------------------
537-
# 2d. Autogenerate round-trip converges (no diff after applying ops)
537+
# 2d. Matching partial indexes should not emit drift
538+
# ---------------------------------------------------------------------------
539+
540+
def test_autogenerate_no_op_when_partial_indexes_match(engine):
541+
_setup_autogen_table(engine, with_index=False)
542+
try:
543+
with engine.begin() as conn:
544+
conn.execute(text(
545+
f'CREATE INDEX "{_AG_IDX}" ON "{_AG_TABLE}" '
546+
f"USING bm25 (id, description) WITH (key_field='id') WHERE (id > 2)"
547+
))
548+
549+
m = MetaData()
550+
t = Table(_AG_TABLE, m, Column("id", Integer, primary_key=True), Column("description", Text))
551+
from sqlalchemy.schema import Index
552+
553+
Index(
554+
_AG_IDX,
555+
BM25Field(t.c.id),
556+
BM25Field(t.c.description),
557+
postgresql_using="bm25",
558+
postgresql_with={"key_field": "id"},
559+
postgresql_where=t.c.id > 2,
560+
)
561+
562+
upgrade_ops = _run_comparator(engine, m)
563+
our_ops = [
564+
op for op in upgrade_ops.ops
565+
if getattr(op, "index_name", None) == _AG_IDX
566+
]
567+
assert not our_ops, f"Expected no ops for matching partial index, got: {our_ops}"
568+
finally:
569+
_teardown_autogen_table(engine)
570+
571+
572+
# ---------------------------------------------------------------------------
573+
# 2e. String-literal case drift in partial predicates should be detected
574+
# ---------------------------------------------------------------------------
575+
576+
def test_autogenerate_detects_changed_partial_string_literal_case(engine):
577+
_setup_autogen_table(engine, with_index=False)
578+
try:
579+
with engine.begin() as conn:
580+
conn.execute(text(
581+
f'CREATE INDEX "{_AG_IDX}" ON "{_AG_TABLE}" '
582+
f"USING bm25 (id, description) WITH (key_field='id') "
583+
f"WHERE (description = 'ACTIVE')"
584+
))
585+
586+
m = MetaData()
587+
t = Table(_AG_TABLE, m, Column("id", Integer, primary_key=True), Column("description", Text))
588+
from sqlalchemy.schema import Index
589+
590+
Index(
591+
_AG_IDX,
592+
BM25Field(t.c.id),
593+
BM25Field(t.c.description),
594+
postgresql_using="bm25",
595+
postgresql_with={"key_field": "id"},
596+
postgresql_where="description = 'active'::text",
597+
)
598+
599+
upgrade_ops = _run_comparator(engine, m)
600+
drop_ops = [
601+
op for op in upgrade_ops.ops
602+
if isinstance(op, pdb_alembic.DropBM25IndexOp) and op.index_name == _AG_IDX
603+
]
604+
create_ops = [
605+
op for op in upgrade_ops.ops
606+
if isinstance(op, pdb_alembic.CreateBM25IndexOp) and op.index_name == _AG_IDX
607+
]
608+
assert len(drop_ops) == 1, "Expected DropBM25IndexOp for string-literal case change"
609+
assert len(create_ops) == 1, "Expected CreateBM25IndexOp for string-literal case change"
610+
finally:
611+
_teardown_autogen_table(engine)
612+
613+
614+
# ---------------------------------------------------------------------------
615+
# 2f. Autogenerate round-trip converges (no diff after applying ops)
538616
# ---------------------------------------------------------------------------
539617

540618
def test_autogenerate_round_trip_converges(engine):
@@ -553,7 +631,7 @@ def test_autogenerate_round_trip_converges(engine):
553631

554632

555633
# ---------------------------------------------------------------------------
556-
# 2e. Full lifecycle: create → query → reindex → query → drop
634+
# 2g. Full lifecycle: create → query → reindex → query → drop
557635
# ---------------------------------------------------------------------------
558636

559637
_LIFECYCLE_TABLE = "alembic_lifecycle_test"
@@ -597,7 +675,7 @@ def test_alembic_create_reindex_drop_is_queryable(engine):
597675

598676

599677
# ---------------------------------------------------------------------------
600-
# 2f. Reindex concurrently with AUTOCOMMIT
678+
# 2h. Reindex concurrently with AUTOCOMMIT
601679
# ---------------------------------------------------------------------------
602680

603681
_CONC_TABLE = "alembic_conc_test"
@@ -638,7 +716,7 @@ def test_alembic_reindex_concurrently_autocommit(engine):
638716

639717

640718
# ---------------------------------------------------------------------------
641-
# 2g. Autogenerate detects changed key_field
719+
# 2i. Autogenerate detects changed key_field
642720
# ---------------------------------------------------------------------------
643721

644722
def test_autogenerate_detects_changed_key_field(engine):
@@ -651,14 +729,15 @@ def test_autogenerate_detects_changed_key_field(engine):
651729
f"USING bm25 (id, description) WITH (key_field='id')"
652730
))
653731

654-
# MetaData declares key_field='description' (different)
732+
# MetaData declares key_field='description' (different) but keeps the
733+
# expression list identical so only key_field drift is under test.
655734
m = MetaData()
656735
t = Table(_AG_TABLE, m, Column("id", Integer, primary_key=True), Column("description", Text))
657736
from sqlalchemy.schema import Index
658737
Index(
659738
_AG_IDX,
660-
BM25Field(t.c.description),
661739
BM25Field(t.c.id),
740+
BM25Field(t.c.description),
662741
postgresql_using="bm25",
663742
postgresql_with={"key_field": "description"},
664743
)
@@ -674,7 +753,7 @@ def test_autogenerate_detects_changed_key_field(engine):
674753

675754

676755
# ---------------------------------------------------------------------------
677-
# 2h. Expression index lifecycle (with tokenizer)
756+
# 2j. Expression index lifecycle (with tokenizer)
678757
# ---------------------------------------------------------------------------
679758

680759
_EXPR_TABLE = "alembic_expr_test"
@@ -719,7 +798,7 @@ def test_alembic_expression_index_lifecycle(engine):
719798

720799

721800
# ---------------------------------------------------------------------------
722-
# 2i. Multi-tokenizer expression lifecycle
801+
# 2k. Multi-tokenizer expression lifecycle
723802
# ---------------------------------------------------------------------------
724803

725804
_MULTI_TABLE = "alembic_multi_tok_test"

0 commit comments

Comments
 (0)