Skip to content

Commit 0f6d8dd

Browse files
Merge pull request #51 from alexander-zuev/fix/nonexistent-migrations-schema
fix(migrations): fix issue with cold start of migration schema
2 parents b798aa2 + 1ebe29d commit 0f6d8dd

14 files changed

+834
-347
lines changed

CHANGELOG.MD

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,12 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
66

77

8-
## [0.3.10] - Unreleased
8+
## [0.3.11] - 2025-03-10
9+
### Fixed
10+
- Fixed an error with creating a migration file when a user doesn't have `supabase_migrations` schema
11+
12+
13+
## [0.3.10] - 2025-03-09
914
### Added
1015
- Enhanced migration naming system with improved object type detection for procedures, functions, and views.
1116
- Expanded `retrieve_migrations` tool with pagination, name pattern filtering, and option to include full SQL queries.

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ dependencies = [
1919
authors = [
2020
{name = "Alexander Zuev", email = "azuev@outlook.com"}
2121
]
22-
keywords = ["supabase", "mcp", "cursor", "windsurf"]
22+
keywords = ["supabase", "mcp", "cursor", "windsurf", "model-context-protocol", "claude", "cline"]
2323
license = "Apache-2.0"
2424
classifiers = [
2525
"Development Status :: 4 - Beta",

supabase_mcp/services/database/migration_manager.py

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import re
44

55
from supabase_mcp.logger import logger
6+
from supabase_mcp.services.database.sql.loader import SQLLoader
67
from supabase_mcp.services.database.sql.models import (
78
QueryValidationResults,
89
SQLQueryCategory,
@@ -13,6 +14,14 @@
1314
class MigrationManager:
1415
"""Responsible for preparing migration scripts without executing them."""
1516

17+
def __init__(self, loader: SQLLoader | None = None):
18+
"""Initialize the migration manager with a SQL loader.
19+
20+
Args:
21+
loader: The SQL loader to use for loading SQL queries
22+
"""
23+
self.loader = loader or SQLLoader()
24+
1625
def prepare_migration_query(
1726
self,
1827
validation_result: QueryValidationResults,
@@ -39,19 +48,15 @@ def prepare_migration_query(
3948
name = self.generate_descriptive_name(validation_result)
4049

4150
# Generate migration version (timestamp)
42-
query_timestamp = self.generate_query_timestamp()
51+
version = self.generate_query_timestamp()
4352

4453
# Escape single quotes in the query for SQL safety
45-
escaped_query = original_query.replace("'", "''")
54+
statements = original_query.replace("'", "''")
4655

47-
# Create the complete migration query with values directly embedded
48-
migration_query = f"""
49-
INSERT INTO supabase_migrations.schema_migrations
50-
(version, name, statements)
51-
VALUES ('{query_timestamp}', '{name}', ARRAY['{escaped_query}']);
52-
"""
56+
# Get the migration query using the loader
57+
migration_query = self.loader.get_create_migration_query(version, name, statements)
5358

54-
logger.info(f"Prepared migration: {query_timestamp}_{name}")
59+
logger.info(f"Prepared migration: {version}_{name}")
5560

5661
# Return the complete query
5762
return migration_query, name

supabase_mcp/services/database/postgres_client.py

Lines changed: 4 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -276,9 +276,8 @@ async def execute_statement(self, conn: asyncpg.Connection[Any], query: str) ->
276276
# Return the result
277277
return StatementResult(rows=rows)
278278

279-
except asyncpg.PostgresError:
280-
# Let the transaction handler deal with this
281-
raise
279+
except asyncpg.PostgresError as e:
280+
await self._handle_postgres_error(e)
282281

283282
@retry(
284283
retry=retry_if_exception_type(
@@ -293,7 +292,7 @@ async def execute_statement(self, conn: asyncpg.Connection[Any], query: str) ->
293292
wait=wait_exponential(multiplier=1, min=2, max=10),
294293
before_sleep=log_db_retry_attempt,
295294
)
296-
async def execute_query_async(
295+
async def execute_query(
297296
self,
298297
validated_query: QueryValidationResults,
299298
readonly: bool = True, # Default to read-only for safety
@@ -339,79 +338,6 @@ async def transaction_operation():
339338
# Execute the operation with a connection
340339
return await self.with_connection(execute_all_statements)
341340

342-
# Keep the original methods but mark them as deprecated
343-
344-
# TODO: This method is now deprecated, use execute_query_async instead
345-
async def _execute_statements(
346-
self,
347-
validated_query: QueryValidationResults,
348-
readonly: bool = False,
349-
) -> QueryResult:
350-
"""Executes one or more statements in a transaction.
351-
352-
DEPRECATED: Use execute_query_async instead.
353-
354-
Args:
355-
validated_query: QueryValidationResults containing parsed statements
356-
readonly: Read-only mode override
357-
358-
Returns:
359-
QueryResult containing combined rows from all statements
360-
"""
361-
# This implementation is kept for backward compatibility
362-
# but will be removed in a future version
363-
async with self._pool.acquire() as conn:
364-
logger.debug(f"Wrapping query in transaction (readonly={readonly})")
365-
# Initialize results list
366-
results: list[StatementResult] = []
367-
368-
async with conn.transaction(readonly=readonly):
369-
for statement in validated_query.statements:
370-
if statement.query:
371-
result = await self._execute_raw_query(conn, statement.query)
372-
# Append each result inside the loop
373-
results.append(result)
374-
375-
# Return combined results
376-
return QueryResult(results=results)
377-
378-
# TODO: This method is now deprecated, use execute_statement instead
379-
async def _execute_raw_query(
380-
self,
381-
conn: asyncpg.Connection[Any],
382-
query: str,
383-
) -> StatementResult:
384-
"""Execute the raw query and process results.
385-
386-
DEPRECATED: Use execute_statement instead.
387-
388-
Args:
389-
conn: Database connection
390-
query: SQL query to execute
391-
392-
Returns:
393-
StatementResult containing rows and metadata
394-
"""
395-
# This implementation is kept for backward compatibility
396-
# but will be removed in a future version
397-
try:
398-
result = await conn.fetch(query)
399-
logger.debug(f"Query executed successfully: {result}")
400-
401-
# Convert records to dictionaries
402-
rows = [dict(record) for record in result]
403-
404-
return StatementResult(rows=rows)
405-
406-
except asyncpg.PostgresError as e:
407-
logger.error(f"PostgreSQL error during query execution: {e}")
408-
# Handle and convert exceptions - this will raise appropriate exceptions
409-
await self._handle_postgres_error(e)
410-
411-
# This line will never be reached as _handle_postgres_error always raises an exception
412-
# But we need it to satisfy the type checker
413-
raise QueryError("Unexpected error occurred")
414-
415341
async def _handle_postgres_error(self, error: asyncpg.PostgresError) -> None:
416342
"""Handle PostgreSQL errors and convert to appropriate exceptions.
417343
@@ -438,4 +364,4 @@ async def _handle_postgres_error(self, error: asyncpg.PostgresError) -> None:
438364
raise QueryError(str(error)) from error
439365
else:
440366
logger.error(f"Database error: {error}")
441-
raise QueryError(f"Query failed: {str(error)}") from error
367+
raise QueryError(f"Query execution failed: {str(error)}") from error
Lines changed: 49 additions & 108 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
1-
from pathlib import Path
2-
31
from supabase_mcp.exceptions import OperationNotAllowedError
42
from supabase_mcp.logger import logger
53
from supabase_mcp.services.database.migration_manager import MigrationManager
64
from supabase_mcp.services.database.postgres_client import PostgresClient, QueryResult
5+
from supabase_mcp.services.database.sql.loader import SQLLoader
76
from supabase_mcp.services.database.sql.models import QueryValidationResults
87
from supabase_mcp.services.database.sql.validator import SQLValidator
98
from supabase_mcp.services.safety.models import ClientType, SafetyMode
@@ -24,26 +23,29 @@ class QueryManager:
2423
validation and execution patterns.
2524
"""
2625

27-
# Path to SQL files directory
28-
SQL_DIR = Path(__file__).parent / "sql" / "queries"
29-
3026
def __init__(
3127
self,
3228
postgres_client: PostgresClient,
3329
safety_manager: SafetyManager,
3430
sql_validator: SQLValidator | None = None,
3531
migration_manager: MigrationManager | None = None,
32+
sql_loader: SQLLoader | None = None,
3633
):
3734
"""
3835
Initialize the QueryManager.
3936
4037
Args:
41-
db_client: The database client to use for executing queries
38+
postgres_client: The database client to use for executing queries
39+
safety_manager: The safety manager to use for validating operations
40+
sql_validator: Optional SQL validator to use
41+
migration_manager: Optional migration manager to use
42+
sql_loader: Optional SQL loader to use
4243
"""
4344
self.db_client = postgres_client
4445
self.safety_manager = safety_manager
4546
self.validator = sql_validator or SQLValidator()
46-
self.migration_manager = migration_manager or MigrationManager()
47+
self.sql_loader = sql_loader or SQLLoader()
48+
self.migration_manager = migration_manager or MigrationManager(loader=self.sql_loader)
4749

4850
def check_readonly(self) -> bool:
4951
"""Returns true if current safety mode is SAFE."""
@@ -103,7 +105,7 @@ async def handle_query_execution(self, validated_query: QueryValidationResults)
103105
QueryResult: The result of the query execution
104106
"""
105107
readonly = self.check_readonly()
106-
result = await self.db_client.execute_query_async(validated_query, readonly)
108+
result = await self.db_client.execute_query(validated_query, readonly)
107109
logger.debug(f"Query result: {result}")
108110
return result
109111

@@ -120,24 +122,42 @@ async def handle_migration(
120122
"""
121123
# 1. Check if migration is needed
122124
if not validation_result.needs_migration():
123-
logger.info("No migration needed for this query")
125+
logger.debug("No migration needed for this query")
124126
return
125127

126-
# 2. Create migration
127-
try:
128-
# Prepare the migration with the original query
129-
migration_query, migration_name = self.migration_manager.prepare_migration_query(
130-
validation_result, original_query, migration_name
131-
)
128+
# 2. Prepare migration query
129+
migration_query, name = self.migration_manager.prepare_migration_query(
130+
validation_result, original_query, migration_name
131+
)
132+
logger.debug("Migration query prepared")
132133

133-
# Validate migration query, since it's a raw query
134-
validated_query = self.validator.validate_query(migration_query)
134+
# 3. Execute migration query
135+
try:
136+
# First, ensure the migration schema exists
137+
await self.init_migration_schema()
135138

136-
await self.handle_query_execution(validated_query)
137-
logger.info(f"Successfully created migration: {migration_name}")
139+
# Then execute the migration query
140+
migration_validation = self.validator.validate_query(migration_query)
141+
await self.db_client.execute_query(migration_validation, readonly=False)
142+
logger.info(f"Migration '{name}' executed successfully")
138143
except Exception as e:
139144
logger.debug(f"Migration failure details: {str(e)}")
140-
raise e
145+
# We don't want to fail the main query if migration fails
146+
# Just log the error and continue
147+
logger.warning(f"Failed to record migration '{name}': {e}")
148+
149+
async def init_migration_schema(self) -> None:
150+
"""Initialize the migrations schema and table if they don't exist."""
151+
try:
152+
# Get the initialization query
153+
init_query = self.sql_loader.get_init_migrations_query()
154+
155+
# Validate and execute it
156+
init_validation = self.validator.validate_query(init_query)
157+
await self.db_client.execute_query(init_validation, readonly=False)
158+
logger.debug("Migrations schema initialized successfully")
159+
except Exception as e:
160+
logger.warning(f"Failed to initialize migrations schema: {e}")
141161

142162
async def handle_confirmation(self, confirmation_id: str) -> QueryResult:
143163
"""
@@ -163,101 +183,22 @@ async def handle_confirmation(self, confirmation_id: str) -> QueryResult:
163183
# Call handle_query with the query and has_confirmation=True
164184
return await self.handle_query(query, has_confirmation=True)
165185

166-
@classmethod
167-
def load_sql(cls, filename: str) -> str:
168-
"""
169-
Load SQL from a file in the sql directory.
170-
171-
Args:
172-
filename: Name of the SQL file (with or without .sql extension)
173-
174-
Returns:
175-
str: The SQL query from the file
176-
177-
Raises:
178-
FileNotFoundError: If the SQL file doesn't exist
179-
"""
180-
# Ensure the filename has .sql extension
181-
if not filename.endswith(".sql"):
182-
filename = f"{filename}.sql"
183-
184-
file_path = cls.SQL_DIR / filename
185-
186-
if not file_path.exists():
187-
logger.error(f"SQL file not found: {file_path}")
188-
raise FileNotFoundError(f"SQL file not found: {file_path}")
189-
190-
with open(file_path) as f:
191-
sql = f.read().strip()
192-
logger.debug(f"Loaded SQL file: {filename} ({len(sql)} chars)")
193-
return sql
194-
195186
def get_schemas_query(self) -> str:
196-
"""
197-
Get SQL query to list all schemas with their sizes and table counts.
198-
199-
Returns:
200-
str: SQL query for listing schemas
201-
"""
202-
logger.debug("Getting schemas query")
203-
return self.load_sql("get_schemas")
187+
"""Get a query to list all schemas."""
188+
return self.sql_loader.get_schemas_query()
204189

205190
def get_tables_query(self, schema_name: str) -> str:
206-
"""
207-
Get SQL query to list all tables in a schema.
208-
209-
Args:
210-
schema_name: Name of the schema
211-
212-
Returns:
213-
str: SQL query for listing tables
214-
"""
215-
logger.debug(f"Getting tables query for schema: {schema_name}")
216-
sql = self.load_sql("get_tables")
217-
return sql.format(schema_name=schema_name)
191+
"""Get a query to list all tables in a schema."""
192+
return self.sql_loader.get_tables_query(schema_name)
218193

219194
def get_table_schema_query(self, schema_name: str, table: str) -> str:
220-
"""
221-
Get SQL query to get detailed table schema.
222-
223-
Args:
224-
schema_name: Name of the schema
225-
table: Name of the table
226-
227-
Returns:
228-
str: SQL query for getting table schema
229-
"""
230-
logger.debug(f"Getting table schema query for {schema_name}.{table}")
231-
sql = self.load_sql("get_table_schema")
232-
return sql.format(schema_name=schema_name, table=table)
195+
"""Get a query to get the schema of a table."""
196+
return self.sql_loader.get_table_schema_query(schema_name, table)
233197

234198
def get_migrations_query(
235199
self, limit: int = 50, offset: int = 0, name_pattern: str = "", include_full_queries: bool = False
236200
) -> str:
237-
"""
238-
Get a query to retrieve migrations from Supabase with filtering and pagination.
239-
240-
Args:
241-
limit: Maximum number of migrations to return (default: 50)
242-
offset: Number of migrations to skip (for pagination)
243-
name_pattern: Optional pattern to filter migrations by name
244-
include_full_queries: Whether to include the full SQL statements in the result
245-
246-
Returns:
247-
str: SQL query to get migrations with the specified filters
248-
"""
249-
logger.debug(f"Getting migrations query with limit={limit}, offset={offset}, name_pattern='{name_pattern}'")
250-
sql = self.load_sql("get_migrations")
251-
252-
# Sanitize inputs to prevent SQL injection
253-
sanitized_limit = max(1, min(100, limit)) # Limit between 1 and 100
254-
sanitized_offset = max(0, offset)
255-
sanitized_name_pattern = name_pattern.replace("'", "''") # Escape single quotes
256-
257-
# Format the SQL query with the parameters
258-
return sql.format(
259-
limit=sanitized_limit,
260-
offset=sanitized_offset,
261-
name_pattern=sanitized_name_pattern,
262-
include_full_queries="true" if include_full_queries else "false",
201+
"""Get a query to list migrations."""
202+
return self.sql_loader.get_migrations_query(
203+
limit=limit, offset=offset, name_pattern=name_pattern, include_full_queries=include_full_queries
263204
)

0 commit comments

Comments
 (0)