Skip to content

Commit e44e22a

Browse files
authored
fix: Support Alembic migration autogeneration (#40)
1 parent 4b7113b commit e44e22a

9 files changed

Lines changed: 628 additions & 16 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file. The format
44

55
## Unreleased
66

7+
## [0.3.0] - 2026-04-07
8+
9+
### Fixed
10+
11+
- Fixed support for auto-generating migrations using Alembic.
12+
713
## [0.2.0] - 2026-04-01
814

915
### Added
@@ -22,5 +28,6 @@ All notable changes to this project will be documented in this file. The format
2228
- CI workflow for lint, typing, unit, and integration checks.
2329
- Example scripts for quickstart, facets, autocomplete, MLT, hybrid RRF, and RAG retrieval.
2430

31+
[0.3.0]: https://github.com/paradedb/sqlalchemy-paradedb/releases/tag/v0.3.0
2532
[0.2.0]: https://github.com/paradedb/sqlalchemy-paradedb/releases/tag/v0.2.0
2633
[0.1.0]: https://github.com/paradedb/sqlalchemy-paradedb/releases/tag/v0.1.0

paradedb/sqlalchemy/alembic.py

Lines changed: 109 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,9 @@ def create_bm25_index(
6666
)
6767
)
6868

69+
def reverse(self) -> MigrateOperation:
70+
return DropBM25IndexOp(index_name=self.index_name, if_exists=True, schema=self.table_schema)
71+
6972

7073
@Operations.implementation_for(CreateBM25IndexOp)
7174
def _create_bm25_index_impl(operations: Operations, operation: CreateBM25IndexOp) -> None:
@@ -97,16 +100,62 @@ def _render_create_bm25_index_op(autogen_context, op: CreateBM25IndexOp) -> str:
97100

98101
@Operations.register_operation("drop_bm25_index")
99102
class DropBM25IndexOp(MigrateOperation):
100-
def __init__(self, index_name: str, if_exists: bool = True, schema: str | None = None) -> None:
103+
def __init__(
104+
self,
105+
index_name: str,
106+
if_exists: bool = True,
107+
schema: str | None = None,
108+
*,
109+
table_name: str | None = None,
110+
expressions: list[str] | None = None,
111+
key_field: str | None = None,
112+
where: str | None = None,
113+
) -> None:
101114
self.index_name = index_name
102115
self.if_exists = if_exists
103116
self.schema = schema
117+
self.table_name = table_name
118+
self.expressions = expressions
119+
self.key_field = key_field
120+
self.where = where
104121

105122
@classmethod
106123
def drop_bm25_index(
107-
cls, operations: Operations, index_name: str, if_exists: bool = True, schema: str | None = None
124+
cls,
125+
operations: Operations,
126+
index_name: str,
127+
if_exists: bool = True,
128+
schema: str | None = None,
129+
*,
130+
table_name: str | None = None,
131+
expressions: list[str] | None = None,
132+
key_field: str | None = None,
133+
where: str | None = None,
108134
) -> MigrateOperation:
109-
return operations.invoke(cls(index_name=index_name, if_exists=if_exists, schema=schema))
135+
return operations.invoke(
136+
cls(
137+
index_name=index_name,
138+
if_exists=if_exists,
139+
schema=schema,
140+
table_name=table_name,
141+
expressions=expressions,
142+
key_field=key_field,
143+
where=where,
144+
)
145+
)
146+
147+
def reverse(self) -> MigrateOperation:
148+
if self.table_name is None or self.expressions is None or self.key_field is None:
149+
raise NotImplementedError("DropBM25IndexOp requires recreate metadata for Alembic downgrade generation")
150+
151+
return CreateBM25IndexOp(
152+
index_name=self.index_name,
153+
table_name=self.table_name,
154+
expressions=self.expressions,
155+
key_field=self.key_field,
156+
table_schema=self.schema,
157+
where=self.where,
158+
)
110159

111160

112161
@Operations.implementation_for(DropBM25IndexOp)
@@ -120,6 +169,14 @@ def _render_drop_bm25_index_op(autogen_context, op: DropBM25IndexOp) -> str:
120169
parts = [repr(op.index_name), f"if_exists={op.if_exists!r}"]
121170
if op.schema is not None:
122171
parts.append(f"schema={op.schema!r}")
172+
if op.table_name is not None:
173+
parts.append(f"table_name={op.table_name!r}")
174+
if op.expressions is not None:
175+
parts.append(f"expressions={op.expressions!r}")
176+
if op.key_field is not None:
177+
parts.append(f"key_field={op.key_field!r}")
178+
if op.where is not None:
179+
parts.append(f"where={op.where!r}")
123180
return f"op.drop_bm25_index({', '.join(parts)})"
124181

125182

@@ -204,12 +261,28 @@ def _render_bm25_expression(expr: ClauseElement) -> str:
204261
return str(expr.compile(dialect=postgresql.dialect(), compile_kwargs={"literal_binds": True})) # type: ignore[no-untyped-call]
205262

206263

207-
def _strip_relation_qualifiers(expr: str, table_name: str) -> str:
208-
# SQLAlchemy may render column refs as `table.col` in metadata compilation;
209-
# CREATE INDEX field lists should be table-local expressions.
210-
stripped = expr.replace(f'"{table_name}".', "")
211-
stripped = stripped.replace(f"{table_name}.", "")
212-
return stripped
264+
def _strip_relation_qualifiers(expr: str, table_name: str, schema_name: str | None) -> str:
265+
# SQLAlchemy may render column refs as `table.col` or `schema.col` in metadata
266+
# compilation; CREATE INDEX field lists should be table-local expressions.
267+
qualifier_patterns: list[str] = []
268+
for name in (schema_name, table_name):
269+
if not name:
270+
continue
271+
qualifier_patterns.append(re.escape(_quote_ident(name)))
272+
qualifier_patterns.append(rf"(?<![\w\"]){re.escape(name)}(?![\w\"])")
273+
274+
if not qualifier_patterns:
275+
return expr
276+
277+
qualifier_re = re.compile(rf"('(?:''|[^'])*')|(?P<qualifier>(?:{'|'.join(qualifier_patterns)}))\.")
278+
279+
def _strip_match(match: re.Match[str]) -> str:
280+
literal = match.group(1)
281+
if literal is not None:
282+
return literal
283+
return ""
284+
285+
return qualifier_re.sub(_strip_match, expr)
213286

214287

215288
def _normalize_bm25_expression(expr: str) -> str:
@@ -312,8 +385,9 @@ def _render_where_from_index(index) -> str | None:
312385
)
313386
),
314387
index.table.name,
388+
index.table.schema,
315389
)
316-
return _strip_relation_qualifiers(str(where_clause), index.table.name)
390+
return _strip_relation_qualifiers(str(where_clause), index.table.name, index.table.schema)
317391

318392

319393
def _suppress_standard_bm25_ops(upgrade_ops, bm25_names: set[str]) -> None:
@@ -363,15 +437,27 @@ def _compare_bm25_indexes(autogen_context, upgrade_ops, schemas) -> PriorityDisp
363437
# Emit drop ops for indexes present in DB but absent from MetaData.
364438
for key in db_bm25:
365439
if key not in meta_bm25:
366-
upgrade_ops.ops.append(DropBM25IndexOp(index_name=key[1], if_exists=True, schema=key[0]))
440+
db = db_bm25[key]
441+
upgrade_ops.ops.append(
442+
DropBM25IndexOp(
443+
index_name=key[1],
444+
if_exists=True,
445+
schema=key[0],
446+
table_name=db["table_name"],
447+
expressions=db["expressions"],
448+
key_field=db["key_field"],
449+
where=db.get("where"),
450+
)
451+
)
367452

368453
# Emit create ops for indexes present in MetaData but absent from DB.
369454
# Also re-create indexes whose expression list, key_field, or WHERE clause differs from the DB.
370455
for key, index in meta_bm25.items():
371456
with_opts = index.dialect_options["postgresql"].get("with") or {}
372457
key_field = with_opts.get("key_field", "")
373458
expressions = [
374-
_strip_relation_qualifiers(_render_bm25_expression(expr), index.table.name) for expr in index.expressions
459+
_strip_relation_qualifiers(_render_bm25_expression(expr), index.table.name, index.table.schema)
460+
for expr in index.expressions
375461
]
376462
meta_where = _render_where_from_index(index)
377463
create_op = CreateBM25IndexOp(
@@ -393,7 +479,17 @@ def _compare_bm25_indexes(autogen_context, upgrade_ops, schemas) -> PriorityDisp
393479
key_field_changed = db["key_field"] != key_field
394480
where_changed = _normalize_where(db.get("where")) != _normalize_where(meta_where)
395481
if expressions_changed or key_field_changed or where_changed:
396-
upgrade_ops.ops.append(DropBM25IndexOp(index_name=key[1], if_exists=True, schema=key[0]))
482+
upgrade_ops.ops.append(
483+
DropBM25IndexOp(
484+
index_name=key[1],
485+
if_exists=True,
486+
schema=key[0],
487+
table_name=db["table_name"],
488+
expressions=db["expressions"],
489+
key_field=db["key_field"],
490+
where=db.get("where"),
491+
)
492+
)
397493
upgrade_ops.ops.append(create_op)
398494

399495
return PriorityDispatchResult.CONTINUE

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "sqlalchemy-paradedb"
7-
version = "0.2.0"
7+
version = "0.3.0"
88
description = "Typed SQLAlchemy helpers for ParadeDB"
99
readme = "README.md"
1010
requires-python = ">=3.10"

tests/integration/alembic/env.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
"""
2+
Alembic configuration used to test generating and running migrations in test_alembic_commands_integration.py
3+
"""
4+
5+
from __future__ import annotations
6+
7+
from alembic import context
8+
from sqlalchemy import engine_from_config, pool
9+
10+
import paradedb.sqlalchemy.alembic # noqa: F401
11+
12+
config = context.config
13+
target_metadata = config.attributes["target_metadata"]
14+
target_table_keys = set(target_metadata.tables)
15+
target_schemas = {table.schema for table in target_metadata.tables.values() if table.schema is not None}
16+
version_table_schema = next(iter(target_schemas), None)
17+
18+
19+
def include_name(name, type_, parent_names):
20+
if type_ == "schema":
21+
return name in target_schemas
22+
if type_ == "table":
23+
schema_name = parent_names.get("schema_name")
24+
if schema_name not in target_schemas:
25+
return False
26+
key = ".".join(part for part in (schema_name, name) if part)
27+
return key in target_table_keys
28+
return True
29+
30+
31+
def include_object(object_, name, type_, reflected, compare_to):
32+
if type_ == "table":
33+
table = object_ if hasattr(object_, "schema") else compare_to
34+
if table is None:
35+
return False
36+
key = ".".join(part for part in (table.schema, table.name) if part)
37+
return key in target_table_keys
38+
return True
39+
40+
41+
def _configure_context(connection) -> None:
42+
context.configure(
43+
connection=connection,
44+
target_metadata=target_metadata,
45+
include_schemas=True,
46+
include_name=include_name,
47+
include_object=include_object,
48+
version_table_schema=version_table_schema,
49+
)
50+
51+
52+
def run_migrations_offline() -> None:
53+
raise RuntimeError("offline migrations are not used in this test")
54+
55+
56+
def run_migrations_online() -> None:
57+
connectable = config.attributes.get("connection")
58+
if connectable is None:
59+
connectable = engine_from_config(
60+
config.get_section(config.config_ini_section, {}),
61+
prefix="sqlalchemy.",
62+
poolclass=pool.NullPool,
63+
)
64+
65+
if hasattr(connectable, "connect"):
66+
with connectable.connect() as connection:
67+
_configure_context(connection)
68+
with context.begin_transaction():
69+
context.run_migrations()
70+
return
71+
72+
_configure_context(connectable)
73+
with context.begin_transaction():
74+
context.run_migrations()
75+
76+
77+
run_migrations_online()
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
"""${message}
2+
3+
Revision ID: ${up_revision}
4+
Revises: ${down_revision | comma,n}
5+
Create Date: ${create_date}
6+
7+
"""
8+
from __future__ import annotations
9+
10+
from alembic import op
11+
import sqlalchemy as sa
12+
${imports if imports else ""}
13+
14+
revision = ${repr(up_revision)}
15+
down_revision = ${repr(down_revision)}
16+
branch_labels = ${repr(branch_labels)}
17+
depends_on = ${repr(depends_on)}
18+
19+
20+
def upgrade() -> None:
21+
${upgrades if upgrades else "pass"}
22+
23+
24+
def downgrade() -> None:
25+
${downgrades if downgrades else "pass"}

tests/integration/conftest.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
from __future__ import annotations
22

33
import os
4+
import shutil
45
from collections.abc import Iterator
6+
from pathlib import Path
57
from typing import Any
68

79
import pytest
10+
from alembic.config import Config
811
from sqlalchemy import Boolean, DateTime, Integer, String, Text, create_engine, text
912
from sqlalchemy.dialects import postgresql
1013
from sqlalchemy.dialects.postgresql import JSONB
@@ -40,6 +43,7 @@ class MockItem(Base):
4043

4144

4245
PARADEDB_SCAN_PROVIDERS = {"ParadeDB Base Scan", "ParadeDB Aggregate Scan", "ParadeDB Join Scan"}
46+
ALEMBIC_TEMPLATE_DIR = Path(__file__).with_name("alembic")
4347

4448

4549
@pytest.fixture(scope="session")
@@ -165,3 +169,19 @@ def mock_session(engine: Engine, paradedb_ready: None) -> Iterator[Session]:
165169
"""Session fixture for tests using the mock_items table."""
166170
with Session(engine) as session:
167171
yield session
172+
173+
174+
@pytest.fixture()
175+
def alembic_config_factory(tmp_path: Path, db_url: str):
176+
def factory(metadata) -> Config:
177+
script_location = tmp_path / "alembic"
178+
shutil.copytree(ALEMBIC_TEMPLATE_DIR, script_location)
179+
(script_location / "versions").mkdir()
180+
181+
config = Config()
182+
config.set_main_option("script_location", str(script_location))
183+
config.set_main_option("sqlalchemy.url", db_url)
184+
config.attributes["target_metadata"] = metadata
185+
return config
186+
187+
return factory

0 commit comments

Comments
 (0)