|
3 | 3 | import pytest |
4 | 4 | from alembic.migration import MigrationContext |
5 | 5 | from alembic.operations import Operations |
6 | | -from sqlalchemy import text |
| 6 | +from alembic.operations.ops import UpgradeOps |
| 7 | +from sqlalchemy import Column, Integer, MetaData, Table, Text, text |
| 8 | +from unittest.mock import MagicMock |
7 | 9 |
|
8 | | -import paradedb.sqlalchemy.alembic # noqa: F401 Ensure op registration |
| 10 | +import paradedb.sqlalchemy.alembic as pdb_alembic # noqa: F401 Ensure op registration |
| 11 | +from paradedb.sqlalchemy.indexing import BM25Field |
9 | 12 |
|
10 | 13 |
|
11 | 14 | pytestmark = pytest.mark.integration |
12 | 15 |
|
13 | 16 |
|
| 17 | +# --------------------------------------------------------------------------- |
| 18 | +# Helper: run the BM25 autogenerate comparator against a real DB connection |
| 19 | +# --------------------------------------------------------------------------- |
| 20 | + |
| 21 | +def _run_comparator(engine, metadata): |
| 22 | + """Return the UpgradeOps produced by the BM25 autogenerate comparator.""" |
| 23 | + with engine.connect() as conn: |
| 24 | + ctx = MagicMock() |
| 25 | + ctx.connection = conn |
| 26 | + ctx.metadata = metadata |
| 27 | + upgrade_ops = UpgradeOps([]) |
| 28 | + pdb_alembic._compare_bm25_indexes(ctx, upgrade_ops, {None}) |
| 29 | + return upgrade_ops |
| 30 | + |
| 31 | + |
14 | 32 | def test_alembic_create_reindex_drop_with_quoted_identifiers(engine): |
15 | 33 | table_name = 'alembic quoted products' |
16 | 34 | index_name = 'alembic quoted idx' |
@@ -57,3 +75,127 @@ def test_alembic_create_reindex_drop_with_quoted_identifiers(engine): |
57 | 75 |
|
58 | 76 | with engine.begin() as conn: |
59 | 77 | conn.execute(text(f'DROP TABLE IF EXISTS "{table_name}"')) |
| 78 | + |
| 79 | + |
| 80 | +# --------------------------------------------------------------------------- |
| 81 | +# Autogenerate comparator integration tests |
| 82 | +# --------------------------------------------------------------------------- |
| 83 | + |
| 84 | +_AG_TABLE = "autogen_test" |
| 85 | +_AG_IDX = "autogen_test_bm25_idx" |
| 86 | + |
| 87 | + |
| 88 | +def _setup_autogen_table(engine, *, with_index: bool = False): |
| 89 | + """Create a clean autogen_test table (and optionally a BM25 index) in the DB.""" |
| 90 | + with engine.begin() as conn: |
| 91 | + conn.execute(text(f'DROP INDEX IF EXISTS "{_AG_IDX}"')) |
| 92 | + conn.execute(text(f'DROP TABLE IF EXISTS "{_AG_TABLE}" CASCADE')) |
| 93 | + conn.execute(text(f'CREATE TABLE "{_AG_TABLE}" (id int primary key, description text not null)')) |
| 94 | + if with_index: |
| 95 | + conn.execute( |
| 96 | + text( |
| 97 | + f'CREATE INDEX "{_AG_IDX}" ON "{_AG_TABLE}" ' |
| 98 | + f"USING bm25 (id, description) WITH (key_field='id')" |
| 99 | + ) |
| 100 | + ) |
| 101 | + |
| 102 | + |
| 103 | +def _teardown_autogen_table(engine): |
| 104 | + with engine.begin() as conn: |
| 105 | + conn.execute(text(f'DROP INDEX IF EXISTS "{_AG_IDX}"')) |
| 106 | + conn.execute(text(f'DROP TABLE IF EXISTS "{_AG_TABLE}" CASCADE')) |
| 107 | + |
| 108 | + |
| 109 | +def _metadata_with_bm25() -> MetaData: |
| 110 | + """MetaData that defines autogen_test with a BM25 index.""" |
| 111 | + m = MetaData() |
| 112 | + t = Table(_AG_TABLE, m, Column("id", Integer, primary_key=True), Column("description", Text)) |
| 113 | + from sqlalchemy.schema import Index |
| 114 | + Index( |
| 115 | + _AG_IDX, |
| 116 | + BM25Field(t.c.id), |
| 117 | + BM25Field(t.c.description), |
| 118 | + postgresql_using="bm25", |
| 119 | + postgresql_with={"key_field": "id"}, |
| 120 | + ) |
| 121 | + return m |
| 122 | + |
| 123 | + |
| 124 | +def _metadata_without_bm25() -> MetaData: |
| 125 | + """MetaData that defines autogen_test WITHOUT any BM25 index.""" |
| 126 | + m = MetaData() |
| 127 | + Table(_AG_TABLE, m, Column("id", Integer, primary_key=True), Column("description", Text)) |
| 128 | + return m |
| 129 | + |
| 130 | + |
| 131 | +def test_autogenerate_detects_missing_index(engine): |
| 132 | + """MetaData has BM25 index but DB does not → CreateBM25IndexOp emitted.""" |
| 133 | + _setup_autogen_table(engine, with_index=False) |
| 134 | + try: |
| 135 | + upgrade_ops = _run_comparator(engine, _metadata_with_bm25()) |
| 136 | + |
| 137 | + create_ops = [op for op in upgrade_ops.ops if isinstance(op, pdb_alembic.CreateBM25IndexOp)] |
| 138 | + assert len(create_ops) == 1 |
| 139 | + op = create_ops[0] |
| 140 | + assert op.index_name == _AG_IDX |
| 141 | + assert op.table_name == _AG_TABLE |
| 142 | + assert op.key_field == "id" |
| 143 | + assert "id" in op.fields |
| 144 | + assert "description" in op.fields |
| 145 | + finally: |
| 146 | + _teardown_autogen_table(engine) |
| 147 | + |
| 148 | + |
| 149 | +def test_autogenerate_detects_extra_index(engine): |
| 150 | + """DB has BM25 index but MetaData does not → DropBM25IndexOp emitted.""" |
| 151 | + _setup_autogen_table(engine, with_index=True) |
| 152 | + try: |
| 153 | + upgrade_ops = _run_comparator(engine, _metadata_without_bm25()) |
| 154 | + |
| 155 | + drop_ops = [op for op in upgrade_ops.ops if isinstance(op, pdb_alembic.DropBM25IndexOp)] |
| 156 | + assert any(op.index_name == _AG_IDX for op in drop_ops) |
| 157 | + finally: |
| 158 | + _teardown_autogen_table(engine) |
| 159 | + |
| 160 | + |
| 161 | +def test_autogenerate_no_op_when_indexes_match(engine): |
| 162 | + """DB and MetaData have identical BM25 index → no create/drop ops for that index.""" |
| 163 | + _setup_autogen_table(engine, with_index=True) |
| 164 | + try: |
| 165 | + upgrade_ops = _run_comparator(engine, _metadata_with_bm25()) |
| 166 | + |
| 167 | + # Filter to only ops for our specific test index; the shared engine fixture's |
| 168 | + # products_bm25_idx may appear as "extra" since our MetaData only knows autogen_test. |
| 169 | + create_ops = [ |
| 170 | + op for op in upgrade_ops.ops |
| 171 | + if isinstance(op, pdb_alembic.CreateBM25IndexOp) and op.index_name == _AG_IDX |
| 172 | + ] |
| 173 | + drop_ops = [ |
| 174 | + op for op in upgrade_ops.ops |
| 175 | + if isinstance(op, pdb_alembic.DropBM25IndexOp) and op.index_name == _AG_IDX |
| 176 | + ] |
| 177 | + assert not create_ops |
| 178 | + assert not drop_ops |
| 179 | + finally: |
| 180 | + _teardown_autogen_table(engine) |
| 181 | + |
| 182 | + |
| 183 | +def test_autogenerate_detects_changed_fields(engine): |
| 184 | + """BM25 index in DB has different fields vs MetaData → Drop + Create emitted.""" |
| 185 | + _setup_autogen_table(engine, with_index=False) |
| 186 | + try: |
| 187 | + # DB index only covers 'id' |
| 188 | + with engine.begin() as conn: |
| 189 | + conn.execute( |
| 190 | + text(f'CREATE INDEX "{_AG_IDX}" ON "{_AG_TABLE}" USING bm25 (id) WITH (key_field=\'id\')') |
| 191 | + ) |
| 192 | + |
| 193 | + # MetaData index covers 'id' and 'description' |
| 194 | + upgrade_ops = _run_comparator(engine, _metadata_with_bm25()) |
| 195 | + |
| 196 | + drop_ops = [op for op in upgrade_ops.ops if isinstance(op, pdb_alembic.DropBM25IndexOp)] |
| 197 | + create_ops = [op for op in upgrade_ops.ops if isinstance(op, pdb_alembic.CreateBM25IndexOp)] |
| 198 | + assert any(op.index_name == _AG_IDX for op in drop_ops), "Expected DropBM25IndexOp" |
| 199 | + assert any(op.index_name == _AG_IDX for op in create_ops), "Expected CreateBM25IndexOp" |
| 200 | + finally: |
| 201 | + _teardown_autogen_table(engine) |
0 commit comments