Skip to content

Commit d1a061f

Browse files
authored
feat: migration convenience methods to config classes (#218)
Introduce new migration methods to database configuration classes, simplifying the migration process by allowing direct calls on config instances.
1 parent de9d6aa commit d1a061f

28 files changed

+1837
-32
lines changed

AGENTS.md

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,94 @@ SQLSpec is a type-safe SQL query mapper designed for minimal abstraction between
183183
- **Parameter Style Abstraction**: Automatically converts between different parameter styles (?, :name, $1, %s)
184184
- **Type Safety**: Supports mapping results to Pydantic, msgspec, attrs, and other typed models
185185
- **Single-Pass Processing**: Parse once → transform once → validate once - SQL object is single source of truth
186+
- **Abstract Methods with Concrete Implementations**: Protocol defines abstract methods, base classes provide concrete sync/async implementations
187+
188+
### Protocol Abstract Methods Pattern
189+
190+
When adding methods that need to support both sync and async configurations, use this pattern:
191+
192+
**Step 1: Define abstract method in protocol**
193+
194+
```python
195+
from abc import abstractmethod
196+
from typing import Awaitable
197+
198+
class DatabaseConfigProtocol(Protocol):
199+
is_async: ClassVar[bool] # Set by base classes
200+
201+
@abstractmethod
202+
def migrate_up(
203+
self, revision: str = "head", allow_missing: bool = False, auto_sync: bool = True, dry_run: bool = False
204+
) -> "Awaitable[None] | None":
205+
"""Apply database migrations up to specified revision.
206+
207+
Args:
208+
revision: Target revision or "head" for latest.
209+
allow_missing: Allow out-of-order migrations.
210+
auto_sync: Auto-reconcile renamed migrations.
211+
dry_run: Show what would be done without applying.
212+
"""
213+
raise NotImplementedError
214+
```
215+
216+
**Step 2: Implement in sync base class (no async/await)**
217+
218+
```python
219+
class NoPoolSyncConfig(DatabaseConfigProtocol):
220+
is_async: ClassVar[bool] = False
221+
222+
def migrate_up(
223+
self, revision: str = "head", allow_missing: bool = False, auto_sync: bool = True, dry_run: bool = False
224+
) -> None:
225+
"""Apply database migrations up to specified revision."""
226+
commands = self._ensure_migration_commands()
227+
commands.upgrade(revision, allow_missing, auto_sync, dry_run)
228+
```
229+
230+
**Step 3: Implement in async base class (with async/await)**
231+
232+
```python
233+
class NoPoolAsyncConfig(DatabaseConfigProtocol):
234+
is_async: ClassVar[bool] = True
235+
236+
async def migrate_up(
237+
self, revision: str = "head", allow_missing: bool = False, auto_sync: bool = True, dry_run: bool = False
238+
) -> None:
239+
"""Apply database migrations up to specified revision."""
240+
commands = cast("AsyncMigrationCommands", self._ensure_migration_commands())
241+
await commands.upgrade(revision, allow_missing, auto_sync, dry_run)
242+
```
243+
244+
**Key principles:**
245+
246+
- Protocol defines the interface with union return type (`Awaitable[T] | T`)
247+
- Sync base classes implement without `async def` or `await`
248+
- Async base classes implement with `async def` and `await`
249+
- Each base class has concrete implementation - no need for child classes to override
250+
- Use `cast()` to narrow types when delegating to command objects
251+
- All 4 base classes (NoPoolSyncConfig, NoPoolAsyncConfig, SyncDatabaseConfig, AsyncDatabaseConfig) implement the same way
252+
253+
**Benefits:**
254+
255+
- Single source of truth (protocol) for API contract
256+
- Each base class provides complete implementation
257+
- Child adapter classes (AsyncpgConfig, SqliteConfig, etc.) inherit working methods automatically
258+
- Type checkers understand sync vs async based on `is_async` class variable
259+
- No code duplication across adapters
260+
261+
**When to use:**
262+
263+
- Adding convenience methods that delegate to external command objects
264+
- Methods that need identical behavior across all adapters
265+
- Operations that differ only in sync vs async execution
266+
- Any protocol method where behavior is determined by sync/async mode
267+
268+
**Anti-patterns to avoid:**
269+
270+
- Don't use runtime `if self.is_async:` checks in a single implementation
271+
- Don't make protocol methods concrete (always use `@abstractmethod`)
272+
- Don't duplicate logic across the 4 base classes
273+
- Don't forget to update all 4 base classes when adding new methods
186274

187275
### Database Connection Flow
188276

docs/changelog.rst

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,72 @@ SQLSpec Changelog
1010
Recent Updates
1111
==============
1212

13+
Migration Convenience Methods on Config Classes
14+
------------------------------------------------
15+
16+
Added migration methods directly to database configuration classes, eliminating the need to instantiate separate command objects.
17+
18+
**What's New:**
19+
20+
All database configs (both sync and async) now provide migration methods:
21+
22+
- ``migrate_up()`` / ``upgrade()`` - Apply migrations up to a revision
23+
- ``migrate_down()`` / ``downgrade()`` - Rollback migrations
24+
- ``get_current_migration()`` - Check current version
25+
- ``create_migration()`` - Create new migration file
26+
- ``init_migrations()`` - Initialize migrations directory
27+
- ``stamp_migration()`` - Stamp database to specific revision
28+
- ``fix_migrations()`` - Convert timestamp to sequential migrations
29+
30+
**Before (verbose):**
31+
32+
.. code-block:: python
33+
34+
from sqlspec.adapters.asyncpg import AsyncpgConfig
35+
from sqlspec.migrations.commands import AsyncMigrationCommands
36+
37+
config = AsyncpgConfig(
38+
pool_config={"dsn": "postgresql://..."},
39+
migration_config={"script_location": "migrations"}
40+
)
41+
42+
commands = AsyncMigrationCommands(config)
43+
await commands.upgrade("head")
44+
45+
**After (recommended):**
46+
47+
.. code-block:: python
48+
49+
from sqlspec.adapters.asyncpg import AsyncpgConfig
50+
51+
config = AsyncpgConfig(
52+
pool_config={"dsn": "postgresql://..."},
53+
migration_config={"script_location": "migrations"}
54+
)
55+
56+
await config.upgrade("head")
57+
58+
**Key Benefits:**
59+
60+
- Simpler API - no need to import and instantiate command classes
61+
- Works with both sync and async adapters
62+
- Full backward compatibility - command classes still available
63+
- Cleaner test fixtures and deployment scripts
64+
65+
**Async Adapters** (AsyncPG, Asyncmy, Aiosqlite, Psqlpy):
66+
67+
.. code-block:: python
68+
69+
await config.migrate_up("head")
70+
await config.create_migration("add users")
71+
72+
**Sync Adapters** (SQLite, DuckDB):
73+
74+
.. code-block:: python
75+
76+
config.migrate_up("head") # No await needed
77+
config.create_migration("add users")
78+
1379
SQL Loader Graceful Error Handling
1480
-----------------------------------
1581

docs/guides/migrations/hybrid-versioning.md

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,92 @@ Always preview before applying:
272272
sqlspec --config myapp.config fix --dry-run
273273
```
274274

275+
## Programmatic API
276+
277+
For Python-based migration automation, use the config method directly instead of CLI commands:
278+
279+
### Async Configuration
280+
281+
```python
282+
from sqlspec.adapters.asyncpg import AsyncpgConfig
283+
284+
config = AsyncpgConfig(
285+
pool_config={"dsn": "postgresql://user:pass@localhost/mydb"},
286+
migration_config={
287+
"enabled": True,
288+
"script_location": "migrations",
289+
}
290+
)
291+
292+
# Preview conversions
293+
await config.fix_migrations(dry_run=True)
294+
295+
# Apply conversions (auto-approve)
296+
await config.fix_migrations(dry_run=False, update_database=True, yes=True)
297+
298+
# Files only (skip database update)
299+
await config.fix_migrations(dry_run=False, update_database=False, yes=True)
300+
```
301+
302+
### Sync Configuration
303+
304+
```python
305+
from sqlspec.adapters.sqlite import SqliteConfig
306+
307+
config = SqliteConfig(
308+
pool_config={"database": "myapp.db"},
309+
migration_config={
310+
"enabled": True,
311+
"script_location": "migrations",
312+
}
313+
)
314+
315+
# Preview conversions (no await needed)
316+
config.fix_migrations(dry_run=True)
317+
318+
# Apply conversions (auto-approve)
319+
config.fix_migrations(dry_run=False, update_database=True, yes=True)
320+
321+
# Files only (skip database update)
322+
config.fix_migrations(dry_run=False, update_database=False, yes=True)
323+
```
324+
325+
### Use Cases
326+
327+
The programmatic API is useful for:
328+
329+
- **Custom deployment scripts** - Integrate migration fixing into deployment automation
330+
- **Testing workflows** - Automate migration testing in CI/CD pipelines
331+
- **Framework integrations** - Build migration support into web framework startup hooks
332+
- **Monitoring tools** - Track migration conversions programmatically
333+
334+
### Example: Custom Deployment Script
335+
336+
```python
337+
import asyncio
338+
from sqlspec.adapters.asyncpg import AsyncpgConfig
339+
340+
async def deploy():
341+
config = AsyncpgConfig(
342+
pool_config={"dsn": "postgresql://..."},
343+
migration_config={"script_location": "migrations"}
344+
)
345+
346+
# Step 1: Convert migrations to sequential
347+
print("Converting migrations to sequential format...")
348+
await config.fix_migrations(dry_run=False, update_database=True, yes=True)
349+
350+
# Step 2: Apply all pending migrations
351+
print("Applying migrations...")
352+
await config.upgrade("head")
353+
354+
# Step 3: Verify current version
355+
current = await config.get_current_migration(verbose=True)
356+
print(f"Deployed to version: {current}")
357+
358+
asyncio.run(deploy())
359+
```
360+
275361
## Best Practices
276362

277363
### 1. Always Use Version Control

0 commit comments

Comments
 (0)