sqlobjects 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
sqlobjects/database.py ADDED
@@ -0,0 +1,586 @@
1
+ from collections.abc import Mapping
2
+ from dataclasses import dataclass
3
+ from typing import Any, Protocol
4
+
5
+ from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
6
+
7
+ from .session import SessionContextManager
8
+
9
+
10
+ __all__ = [
11
+ "DatabaseConfig",
12
+ "Database",
13
+ "DatabaseManager",
14
+ "DatabaseObserver",
15
+ "init_db",
16
+ "init_dbs",
17
+ "create_tables",
18
+ "drop_tables",
19
+ "close_db",
20
+ "close_dbs",
21
+ "close_all_dbs",
22
+ "set_default_db",
23
+ ]
24
+
25
+
26
+ class DatabaseObserver(Protocol):
27
+ """Database event observer protocol"""
28
+
29
+ def on_database_added(self, name: str, database: "Database", is_default: bool) -> None: ...
30
+ def on_database_closed(self, name: str) -> None: ...
31
+ def on_default_changed(self, old_default: str | None, new_default: str | None) -> None: ...
32
+
33
+
34
+ @dataclass(init=False)
35
+ class DatabaseConfig:
36
+ """Database configuration class
37
+
38
+ Uses dataclass to automatically generate initialization and other methods.
39
+
40
+ Attributes:
41
+ url: Database connection URL
42
+ echo: Whether to print SQL statements
43
+ pool_size: Connection pool size
44
+ max_overflow: Maximum pool overflow count
45
+ pool_timeout: Connection timeout in seconds
46
+ pool_recycle: Connection recycle time in seconds
47
+ engine_kwargs: Additional SQLAlchemy engine parameters
48
+
49
+ Examples:
50
+ >>> config = DatabaseConfig(
51
+ ... url="postgresql+asyncpg://user:pass@localhost/mydb",
52
+ ... pool_size=10,
53
+ ... echo=True,
54
+ ... isolation_level="READ_COMMITTED",
55
+ ... )
56
+ """
57
+
58
+ url: str
59
+ echo: bool
60
+ pool_size: int
61
+ max_overflow: int
62
+ pool_timeout: int
63
+ pool_recycle: int
64
+ engine_kwargs: dict[str, Any]
65
+
66
+ def __init__(
67
+ self,
68
+ url: str,
69
+ echo: bool = False,
70
+ pool_size: int = 5,
71
+ max_overflow: int = 10,
72
+ pool_timeout: int = 30,
73
+ pool_recycle: int = 3600,
74
+ **kwargs: Any,
75
+ ) -> None:
76
+ """Initialize database configuration
77
+
78
+ Args:
79
+ url: Database connection URL
80
+ echo: Whether to print SQL statements
81
+ pool_size: Connection pool size
82
+ max_overflow: Maximum pool overflow count
83
+ pool_timeout: Connection timeout in seconds
84
+ pool_recycle: Connection recycle time in seconds
85
+ **kwargs: Additional SQLAlchemy engine parameters
86
+ """
87
+ self.url = url
88
+ self.echo = echo
89
+ self.pool_size = pool_size
90
+ self.max_overflow = max_overflow
91
+ self.pool_timeout = pool_timeout
92
+ self.pool_recycle = pool_recycle
93
+ self.engine_kwargs = kwargs
94
+
95
+
96
+ class Database:
97
+ """Single database connection with event handling and table operations
98
+
99
+ Represents a single database connection, providing unified event registration
100
+ interface and table operation methods.
101
+
102
+ Attributes:
103
+ name: Unique name for the database connection
104
+ config: Database configuration
105
+ engine: SQLAlchemy async engine
106
+
107
+ Examples:
108
+ >>> config = DatabaseConfig(url="sqlite+aiosqlite:///test.db")
109
+ >>> db = Database("main", config)
110
+ >>> @db.on("connect")
111
+ ... def on_connect(conn, record):
112
+ ... print("Database connected")
113
+ """
114
+
115
+ def __init__(self, name: str, config: DatabaseConfig) -> None:
116
+ """Initialize database instance
117
+
118
+ Args:
119
+ name: Unique name for the database connection
120
+ config: Database configuration
121
+ """
122
+ self.name = name
123
+ self.config = config
124
+
125
+ # Build engine parameters
126
+ engine_kwargs: dict[str, Any] = {
127
+ "echo": config.echo,
128
+ **config.engine_kwargs,
129
+ }
130
+
131
+ # Add connection pool parameters for non-SQLite databases
132
+ if not config.url.startswith("sqlite"):
133
+ engine_kwargs.update(
134
+ {
135
+ "pool_size": config.pool_size,
136
+ "max_overflow": config.max_overflow,
137
+ "pool_timeout": config.pool_timeout,
138
+ "pool_recycle": config.pool_recycle,
139
+ }
140
+ )
141
+
142
+ # Create async engine
143
+ self.engine: AsyncEngine = create_async_engine(config.url, **engine_kwargs)
144
+
145
+ def on(self, event_name: str, target=None):
146
+ """Unified event registration method
147
+
148
+ Args:
149
+ event_name: Event name (connect, close, before_commit, etc.)
150
+ target: Event target, automatically selected by default
151
+
152
+ Returns:
153
+ SQLAlchemy event listener decorator
154
+
155
+ Examples:
156
+ >>> @db.on("connect")
157
+ ... def on_connect(conn, record):
158
+ ... print("Database connected")
159
+
160
+ >>> @db.on("before_commit")
161
+ ... def before_commit(conn):
162
+ ... print("About to commit transaction")
163
+ """
164
+ from sqlalchemy import event
165
+
166
+ # Automatically select event target
167
+ if target is None:
168
+ target = self.engine.sync_engine
169
+
170
+ return event.listens_for(target, event_name)
171
+
172
+ async def create_tables(self, metadata) -> None:
173
+ """Create all tables defined in the metadata
174
+
175
+ Creates all tables, indexes, and constraints defined in the provided
176
+ SQLAlchemy metadata object using the database engine.
177
+
178
+ Args:
179
+ metadata: SQLAlchemy metadata object containing table definitions
180
+
181
+ Examples:
182
+ >>> from sqlobjects.base import ObjectModel
183
+ >>> await db.create_tables(ObjectModel.__registry__)
184
+ """
185
+ async with self.engine.begin() as conn:
186
+ await conn.run_sync(metadata.create_all)
187
+
188
+ async def drop_tables(self, metadata) -> None:
189
+ """Drop all tables defined in the metadata
190
+
191
+ Drops all tables, indexes, and constraints defined in the provided
192
+ SQLAlchemy metadata object from the database.
193
+
194
+ Args:
195
+ metadata: SQLAlchemy metadata object containing table definitions
196
+
197
+ Examples:
198
+ >>> from sqlobjects.base import ObjectModel
199
+ >>> await db.drop_tables(ObjectModel.__registry__)
200
+ """
201
+ async with self.engine.begin() as conn:
202
+ await conn.run_sync(metadata.drop_all)
203
+
204
+ async def disconnect(self) -> None:
205
+ """Disconnect database and clean up resources
206
+
207
+ Properly disposes the SQLAlchemy engine and closes all connections.
208
+ Should be called when the database is no longer needed.
209
+ """
210
+ await self.engine.dispose()
211
+
212
+
213
+ class DatabaseManager:
214
+ """Multi-database connection manager
215
+
216
+ Manages multiple database connections, handles default database selection,
217
+ provides table operations and connection lifecycle management.
218
+ Uses observer pattern to decouple from ConnectionContextManager.
219
+
220
+ Examples:
221
+ >>> manager = DatabaseManager()
222
+ >>> await manager.add_database("main", main_config, is_default=True)
223
+ >>> await manager.add_database("analytics", analytics_config)
224
+ >>> await manager.create_tables(ObjectModel, "main")
225
+ """
226
+
227
+ def __init__(self) -> None:
228
+ """Initialize database manager"""
229
+ self._databases: dict[str, Database] = {}
230
+ self._default_db: str | None = None
231
+ self._observers: list[DatabaseObserver] = []
232
+
233
+ def _notify_observers(self, event: str, **kwargs) -> None:
234
+ """Notify all observers"""
235
+ for observer in self._observers:
236
+ getattr(observer, event)(**kwargs)
237
+
238
+ def add_observer(self, observer: DatabaseObserver) -> None:
239
+ """Add database event observer
240
+
241
+ Args:
242
+ observer: Observer instance implementing DatabaseObserver protocol
243
+ """
244
+ self._observers.append(observer)
245
+
246
+ def remove_observer(self, observer: DatabaseObserver) -> None:
247
+ """Remove database event observer
248
+
249
+ Args:
250
+ observer: Observer instance to remove
251
+
252
+ Raises:
253
+ ValueError: When observer is not found in the list
254
+ """
255
+ self._observers.remove(observer)
256
+
257
+ async def add_database(self, name: str, config: DatabaseConfig, is_default: bool = False) -> Database:
258
+ """Add database connection
259
+
260
+ Args:
261
+ name: Unique database name
262
+ config: Database configuration
263
+ is_default: Whether to set as default database
264
+
265
+ Returns:
266
+ Created database instance
267
+
268
+ Raises:
269
+ ValueError: When database connection fails
270
+ """
271
+ try:
272
+ database = Database(name, config)
273
+ self._databases[name] = database
274
+
275
+ old_default = self._default_db
276
+ if is_default:
277
+ self._default_db = name
278
+
279
+ # Notify observers
280
+ self._notify_observers("on_database_added", name=name, database=database, is_default=is_default)
281
+ if is_default and old_default != name:
282
+ self._notify_observers("on_default_changed", old_default=old_default, new_default=name)
283
+
284
+ return database
285
+ except Exception as e:
286
+ raise ValueError(f"Failed to connect to database '{name}': {e}") from e
287
+
288
+ def get_database(self, db_name: str | None = None) -> Database:
289
+ """Get database instance
290
+
291
+ Args:
292
+ db_name: Database name, uses default database when None
293
+
294
+ Returns:
295
+ Database instance
296
+
297
+ Raises:
298
+ ValueError: When database does not exist
299
+ """
300
+ name = db_name or self._default_db
301
+ if not name or name not in self._databases:
302
+ raise ValueError(f"Database '{name}' not found")
303
+ return self._databases[name]
304
+
305
+ async def create_tables(self, base_class, db_name: str | None = None) -> None:
306
+ """Create all tables defined in the base class registry
307
+
308
+ Creates all tables, indexes, and constraints for models registered
309
+ in the base class registry using the specified database connection.
310
+
311
+ Args:
312
+ base_class: SQLObjects base class containing model registry
313
+ db_name: Database name to use, uses default database if None
314
+
315
+ Raises:
316
+ ValueError: When specified database does not exist
317
+
318
+ Examples:
319
+ >>> from sqlobjects.base import ObjectModel
320
+ >>> await manager.create_tables(ObjectModel)
321
+ >>> await manager.create_tables(ObjectModel, "analytics")
322
+ """
323
+ database = self.get_database(db_name)
324
+ await database.create_tables(base_class.__registry__)
325
+
326
+ async def drop_tables(self, base_class, db_name: str | None = None) -> None:
327
+ """Drop all tables defined in the base class registry
328
+
329
+ Drops all tables, indexes, and constraints for models registered
330
+ in the base class registry from the specified database connection.
331
+
332
+ Args:
333
+ base_class: SQLObjects base class containing model registry
334
+ db_name: Database name to use, uses default database if None
335
+
336
+ Raises:
337
+ ValueError: When specified database does not exist
338
+
339
+ Examples:
340
+ >>> from sqlobjects.base import ObjectModel
341
+ >>> await manager.drop_tables(ObjectModel)
342
+ >>> await manager.drop_tables(ObjectModel, "analytics")
343
+ """
344
+ database = self.get_database(db_name)
345
+ await database.drop_tables(base_class.__registry__)
346
+
347
+ async def close(self, db_name: str | None = None, auto_default: bool = False) -> None:
348
+ """Close database connection and clean up resources
349
+
350
+ Closes the specified database connection and removes it from the manager.
351
+ Handles default database reassignment when closing the default database.
352
+
353
+ Args:
354
+ db_name: Database name to close, closes default when None
355
+ auto_default: Whether to automatically select new default when closing default database
356
+
357
+ Raises:
358
+ ValueError: When specified database does not exist
359
+ """
360
+ # Determine target database name
361
+ target_db = db_name or self._default_db
362
+ if not target_db or target_db not in self._databases:
363
+ raise ValueError(f"Database '{target_db}' not found")
364
+
365
+ # Close specified database
366
+ await self._databases[target_db].engine.dispose()
367
+ del self._databases[target_db]
368
+ self._notify_observers("on_database_closed", name=target_db)
369
+
370
+ # Handle default database change if closing default
371
+ if self._default_db == target_db:
372
+ old_default = self._default_db
373
+ if auto_default:
374
+ self._default_db = next(iter(self._databases), None)
375
+ else:
376
+ self._default_db = None
377
+ self._notify_observers("on_default_changed", old_default=old_default, new_default=self._default_db)
378
+
379
+ async def close_all(self) -> None:
380
+ """Close all database connections and clean up resources
381
+
382
+ Closes all managed database connections, disposes their engines,
383
+ and resets the manager to initial state.
384
+ """
385
+ for name, db in self._databases.items():
386
+ await db.engine.dispose()
387
+ self._notify_observers("on_database_closed", name=name)
388
+
389
+ old_default = self._default_db
390
+ self._databases.clear()
391
+ self._default_db = None
392
+ if old_default:
393
+ self._notify_observers("on_default_changed", old_default=old_default, new_default=None)
394
+
395
+ def set_default_db(self, db_name: str) -> None:
396
+ """Set default database for operations
397
+
398
+ Changes the default database used when no specific database
399
+ name is provided in operations.
400
+
401
+ Args:
402
+ db_name: Database name to set as default
403
+
404
+ Raises:
405
+ ValueError: When database does not exist
406
+ """
407
+ if db_name not in self._databases:
408
+ raise ValueError(f"Database '{db_name}' not found")
409
+
410
+ old_default = self._default_db
411
+ self._default_db = db_name
412
+ self._notify_observers("on_default_changed", old_default=old_default, new_default=db_name)
413
+
414
+
415
+ # Global database manager instance
416
+ _manager = DatabaseManager()
417
+
418
+ # Register TransactionContextManager as observer
419
+ _manager.add_observer(SessionContextManager)
420
+
421
+
422
+ async def init_db(
423
+ url: str,
424
+ name: str | None = None,
425
+ echo: bool = False,
426
+ pool_size: int = 5,
427
+ max_overflow: int = 10,
428
+ pool_timeout: int = 30,
429
+ pool_recycle: int = 3600,
430
+ is_default: bool = True,
431
+ **engine_kwargs: Any,
432
+ ) -> Database:
433
+ """Initialize single database connection.
434
+
435
+ Args:
436
+ url: Database URL (e.g., 'sqlite+aiosqlite:///db.sqlite', 'postgresql+asyncpg://user:pass@host/db')
437
+ name: Name for the database connection, uses "default" if None
438
+ echo: Whether to log all SQL statements
439
+ pool_size: Number of connections to maintain in the pool
440
+ max_overflow: Maximum number of connections that can overflow the pool
441
+ pool_timeout: Timeout in seconds for getting connection from pool
442
+ pool_recycle: Time in seconds to recycle connections
443
+ is_default: Whether this database should be set as the default database
444
+ **engine_kwargs: Additional SQLAlchemy engine arguments
445
+
446
+ Returns:
447
+ Database instance with configured connection
448
+
449
+ Raises:
450
+ ValueError: If database URL format is invalid
451
+ DatabaseError: If connection to database fails
452
+ ImportError: If required database driver is not installed
453
+ """
454
+ config = DatabaseConfig(
455
+ url=url,
456
+ echo=echo,
457
+ pool_size=pool_size,
458
+ max_overflow=max_overflow,
459
+ pool_timeout=pool_timeout,
460
+ pool_recycle=pool_recycle,
461
+ **engine_kwargs,
462
+ )
463
+ db_name = name if name is not None else "default"
464
+ return await _manager.add_database(db_name, config, is_default=is_default)
465
+
466
+
467
+ async def init_dbs(
468
+ databases: Mapping[str, dict[str, Any] | DatabaseConfig],
469
+ default: str | None = None,
470
+ ) -> tuple[Database, ...]:
471
+ """Initialize multiple database connections.
472
+
473
+ Args:
474
+ databases: Dictionary mapping database names to their configurations
475
+ default: Name of the default database to use when none is specified, or None for no default
476
+
477
+ Returns:
478
+ Tuple of Database instances in the order they appear in the databases dict
479
+
480
+ Raises:
481
+ ValueError: If default database name is not in databases dict or URL format is invalid
482
+ DatabaseError: If connection to any database fails
483
+ ImportError: If required database drivers are not installed
484
+ """
485
+ db_instances = []
486
+
487
+ for name, config_data in databases.items():
488
+ if isinstance(config_data, DatabaseConfig):
489
+ config = config_data
490
+ else:
491
+ config = DatabaseConfig(**config_data)
492
+
493
+ is_default = default is not None and name == default
494
+ database = await _manager.add_database(name, config, is_default)
495
+ db_instances.append(database)
496
+
497
+ return tuple(db_instances)
498
+
499
+
500
+ async def create_tables(base_class, db_name: str | None = None) -> None:
501
+ """Create all tables defined in the base class registry
502
+
503
+ Creates all tables, indexes, and constraints for models registered
504
+ in the base class registry using the specified database connection.
505
+
506
+ Args:
507
+ base_class: SQLObjects base class containing model registry
508
+ db_name: Name of the database, uses default if None
509
+
510
+ Raises:
511
+ ValueError: When specified database does not exist
512
+ """
513
+ await _manager.create_tables(base_class, db_name)
514
+
515
+
516
+ async def drop_tables(base_class, db_name: str | None = None) -> None:
517
+ """Drop all tables defined in the base class registry
518
+
519
+ Drops all tables, indexes, and constraints for models registered
520
+ in the base class registry from the specified database connection.
521
+
522
+ Args:
523
+ base_class: SQLObjects base class containing model registry
524
+ db_name: Name of the database, uses default if None
525
+
526
+ Raises:
527
+ ValueError: When specified database does not exist
528
+ """
529
+ await _manager.drop_tables(base_class, db_name)
530
+
531
+
532
+ async def close_db(db_name: str | None = None, auto_default: bool = False) -> None:
533
+ """Close database connection and clean up resources
534
+
535
+ Closes the specified database connection and removes it from the manager.
536
+ Handles default database reassignment when closing the default database.
537
+
538
+ Args:
539
+ db_name: Name of specific database to close, closes default if None
540
+ auto_default: Whether to update default database when closing the default database
541
+
542
+ Raises:
543
+ ValueError: When specified database does not exist
544
+ """
545
+ await _manager.close(db_name, auto_default)
546
+
547
+
548
+ async def close_dbs(db_names: list[str], auto_default: bool = False) -> None:
549
+ """Close multiple specific database connections
550
+
551
+ Closes all specified database connections in sequence.
552
+ Handles default database reassignment if the default database is closed.
553
+
554
+ Args:
555
+ db_names: List of database names to close
556
+ auto_default: Whether to update default database when closing the default database
557
+
558
+ Raises:
559
+ ValueError: When any specified database does not exist
560
+ """
561
+ for db_name in db_names:
562
+ await _manager.close(db_name, auto_default)
563
+
564
+
565
+ async def close_all_dbs() -> None:
566
+ """Close all database connections and clean up resources
567
+
568
+ Closes all managed database connections, disposes their engines,
569
+ and resets the global manager to initial state.
570
+ """
571
+ await _manager.close_all()
572
+
573
+
574
+ def set_default_db(db_name: str) -> None:
575
+ """Set the default database by name
576
+
577
+ Changes the default database used when no specific database
578
+ name is provided in operations.
579
+
580
+ Args:
581
+ db_name: Name of the database to set as default
582
+
583
+ Raises:
584
+ ValueError: If database is not found
585
+ """
586
+ _manager.set_default_db(db_name)