@@ -83,7 +83,15 @@ def get_db_url(
8383 logger .info (f"Using Postgres database: { config .database_url } " )
8484 return config .database_url
8585
86- # SQLite databases
86+ # --- SQLite URL Handling ---
87+ # Trigger: database_url is set with a SQLite URL
88+ # Why: allows custom SQLite paths via URL configuration
89+ # Outcome: use the provided URL instead of constructing from db_path
90+ if config .database_url and config .database_url .startswith ("sqlite" ):
91+ logger .info (f"Using SQLite database from URL: { config .database_url } " )
92+ return config .database_url
93+
94+ # SQLite databases (default behavior)
8795 if db_type == cls .MEMORY :
8896 logger .info ("Using in-memory SQLite database" )
8997 return "sqlite+aiosqlite://"
@@ -206,6 +214,34 @@ def enable_wal_mode(dbapi_conn, connection_record):
206214 return engine
207215
208216
217+ def extract_search_path_from_url (db_url : str ) -> tuple [str , str ]:
218+ """Extract search_path from Postgres URL and return clean URL.
219+
220+ Args:
221+ db_url: Postgres connection URL, possibly with ?search_path=schema
222+
223+ Returns:
224+ Tuple of (clean_url without search_path, search_path value)
225+
226+ Why: asyncpg rejects search_path as a URL query parameter, so we extract it
227+ and pass it via server_settings instead.
228+ """
229+ from urllib .parse import urlparse , parse_qs , urlencode , urlunparse
230+
231+ parsed = urlparse (db_url )
232+ query_params = parse_qs (parsed .query )
233+
234+ # Extract search_path, default to "public"
235+ search_path_list = query_params .pop ("search_path" , ["public" ])
236+ search_path = search_path_list [0 ] if search_path_list else "public"
237+
238+ # Rebuild URL without search_path
239+ new_query = urlencode (query_params , doseq = True )
240+ clean_url = urlunparse (parsed ._replace (query = new_query ))
241+
242+ return clean_url , search_path
243+
244+
209245def _create_postgres_engine (db_url : str , config : BasicMemoryConfig ) -> AsyncEngine :
210246 """Create Postgres async engine with appropriate configuration.
211247
@@ -216,10 +252,16 @@ def _create_postgres_engine(db_url: str, config: BasicMemoryConfig) -> AsyncEngi
216252 Returns:
217253 Configured async engine for Postgres
218254 """
255+ # --- Extract search_path from URL ---
256+ # Trigger: URL contains ?search_path=schema parameter
257+ # Why: asyncpg rejects search_path as URL param, must pass via server_settings
258+ # Outcome: clean URL for asyncpg, search_path passed to server_settings
259+ clean_url , search_path = extract_search_path_from_url (db_url )
260+
219261 # Use NullPool connection issues.
220262 # Assume connection pooler like PgBouncer handles connection pooling.
221263 engine = create_async_engine (
222- db_url ,
264+ clean_url ,
223265 echo = False ,
224266 poolclass = NullPool , # No pooling - fresh connection per request
225267 connect_args = {
@@ -233,10 +275,12 @@ def _create_postgres_engine(db_url: str, config: BasicMemoryConfig) -> AsyncEngi
233275 "application_name" : "basic-memory" ,
234276 # Statement timeout for queries (30s to allow for cold start)
235277 "statement_timeout" : "30s" ,
278+ # Schema isolation via search_path (extracted from URL or default "public")
279+ "search_path" : search_path ,
236280 },
237281 },
238282 )
239- logger .debug ("Created Postgres engine with NullPool (no connection pooling) " )
283+ logger .debug (f "Created Postgres engine with search_path= { search_path } " )
240284
241285 return engine
242286
@@ -365,6 +409,44 @@ async def engine_session_factory(
365409 _session_maker = None
366410
367411
412+ def get_search_path_from_config (app_config : BasicMemoryConfig ) -> Optional [str ]:
413+ """Extract search_path from config's database_url if present.
414+
415+ Args:
416+ app_config: BasicMemoryConfig with database_url
417+
418+ Returns:
419+ search_path value if present and not "public", else None
420+ """
421+ if not app_config .database_url :
422+ return None
423+
424+ if not app_config .database_url .startswith ("postgresql" ):
425+ return None
426+
427+ _ , search_path = extract_search_path_from_url (app_config .database_url )
428+ return search_path if search_path != "public" else None
429+
430+
431+ async def ensure_schema_exists (engine : AsyncEngine , schema : str ) -> None :
432+ """Create schema if it doesn't exist (Postgres only).
433+
434+ Args:
435+ engine: AsyncEngine connected to Postgres
436+ schema: Schema name to create
437+
438+ Why: When using search_path for schema isolation, the schema must exist
439+ before migrations can create tables in it.
440+ """
441+ if not schema or schema == "public" :
442+ return
443+
444+ async with engine .begin () as conn :
445+ # Use text() to execute raw SQL - schema names are trusted config values
446+ await conn .execute (text (f'CREATE SCHEMA IF NOT EXISTS "{ schema } "' ))
447+ logger .info (f"Ensured schema exists: { schema } " )
448+
449+
368450async def run_migrations (
369451 app_config : BasicMemoryConfig , database_type = DatabaseType .FILESYSTEM
370452): # pragma: no cover
@@ -393,6 +475,22 @@ async def run_migrations(
393475 db_url = DatabaseType .get_db_url (app_config .database_path , database_type , app_config )
394476 config .set_main_option ("sqlalchemy.url" , db_url )
395477
478+ # --- Schema Creation for Postgres ---
479+ # Trigger: Postgres backend with non-public search_path in URL
480+ # Why: schema must exist before Alembic can create tables in it
481+ # Outcome: CREATE SCHEMA IF NOT EXISTS runs before migrations
482+ search_path = get_search_path_from_config (app_config )
483+ if search_path and (
484+ database_type == DatabaseType .POSTGRES
485+ or app_config .database_backend == DatabaseBackend .POSTGRES
486+ ):
487+ # Create a temporary engine just for schema creation
488+ temp_engine = _create_postgres_engine (db_url , app_config )
489+ try :
490+ await ensure_schema_exists (temp_engine , search_path )
491+ finally :
492+ await temp_engine .dispose ()
493+
396494 command .upgrade (config , "head" )
397495 logger .info ("Migrations completed successfully" )
398496
0 commit comments