diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/connections/manager.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/connections/manager.py index b7c10262..bc6e06b7 100644 --- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/connections/manager.py +++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/connections/manager.py @@ -1,5 +1,7 @@ from typing import TYPE_CHECKING +from fastapi_startkit.exceptions.exceptions import DriverNotFound + if TYPE_CHECKING: from fastapi_startkit.masoniteorm.connections.factory import ConnectionFactory @@ -9,6 +11,45 @@ def __init__(self, factory: "ConnectionFactory", config: dict): self.factory = factory self.config = config self.connections = {} + self._validate_config() + + def _validate_config(self) -> None: + """Validate every configured connection up front so a bad config fails + loudly at app-boot/provider-registration time, instead of lazily on + first use (e.g. db:migrate or the first query).""" + from fastapi_startkit.masoniteorm.connections.factory import ConnectionFactory + + for name, conn_config in self.config.get("connections", {}).items(): + if conn_config.get("url"): + # An explicit url bypasses driver/host/port assembly entirely at + # connection time too (see ConnectionFactory.build_url()), so it + # is exempt from the checks below. + continue + + driver = conn_config.get("driver") + if not driver: + raise DriverNotFound( + f"Connection '{name}' is missing a required 'driver' key. " + f"Supported drivers are: {', '.join(sorted(ConnectionFactory.DRIVER_URLS))}." + ) + + if driver not in ConnectionFactory.DRIVER_URLS: + raise DriverNotFound( + f"Connection '{name}' has an unsupported driver {driver!r}. " + f"Supported drivers are: {', '.join(sorted(ConnectionFactory.DRIVER_URLS))}." + ) + + if driver == "sqlite": + continue + + port = conn_config.get("port") + if port not in (None, ""): + try: + int(port) + except (TypeError, ValueError): + raise ValueError( + f"Connection '{name}' has an invalid 'port' value {port!r}; expected a number." + ) from None def connection(self, name: str | None = None): name = self.get_default_connection_name(name) diff --git a/fastapi_startkit/tests/masoniteorm/config/test_manager.py b/fastapi_startkit/tests/masoniteorm/config/test_manager.py index dd6589ec..1c18a1c1 100644 --- a/fastapi_startkit/tests/masoniteorm/config/test_manager.py +++ b/fastapi_startkit/tests/masoniteorm/config/test_manager.py @@ -3,6 +3,7 @@ from pathlib import Path from fastapi_startkit.application import Application +from fastapi_startkit.exceptions.exceptions import DriverNotFound from fastapi_startkit.masoniteorm import SQLiteConfig from fastapi_startkit.masoniteorm.connections.factory import ConnectionFactory from fastapi_startkit.masoniteorm.connections.manager import DatabaseManager @@ -52,6 +53,104 @@ def test_missing_connection_raises_value_error_not_key_error(self): manager.connection("sqlite") +class TestDatabaseManagerBootTimeValidation(unittest.TestCase): + """Task #1232: a misconfigured connection must fail loudly at + construction (app-boot/provider-registration) time, not lazily on first + use — building on top of PR #194's DriverNotFound exception. + """ + + def test_valid_configs_still_construct_without_error(self): + """No behavior change for well-formed configs (sqlite/mysql/postgres).""" + config = { + "default": "sqlite", + "connections": { + "sqlite": {"driver": "sqlite", "database": "app.sqlite3"}, + "mysql": {"driver": "mysql", "host": "localhost", "database": "db"}, + "postgres": {"driver": "postgres", "host": "localhost", "port": 5432, "database": "db"}, + }, + } + + manager = DatabaseManager(ConnectionFactory(), config) + + self.assertIsInstance(manager, DatabaseManager) + + def test_missing_driver_key_raises_at_construction(self): + config = { + "default": "sqlite", + "connections": {"sqlite": {"database": "app.sqlite3"}}, + } + + with self.assertRaises(DriverNotFound) as ctx: + DatabaseManager(ConnectionFactory(), config) + self.assertIn("sqlite", str(ctx.exception)) + + def test_unsupported_driver_raises_at_construction(self): + config = { + "default": "mssql", + "connections": {"mssql": {"driver": "mssql", "host": "localhost", "database": "db"}}, + } + + with self.assertRaises(DriverNotFound) as ctx: + DatabaseManager(ConnectionFactory(), config) + self.assertIn("mssql", str(ctx.exception)) + + def test_non_numeric_port_raises_at_construction(self): + config = { + "default": "postgres", + "connections": { + "postgres": {"driver": "postgres", "host": "localhost", "port": "not-a-port", "database": "db"} + }, + } + + with self.assertRaises(ValueError) as ctx: + DatabaseManager(ConnectionFactory(), config) + self.assertIn("port", str(ctx.exception)) + + def test_missing_port_is_not_an_error(self): + """Missing/empty port is not a misconfiguration -- ConnectionFactory + fills in a per-driver default, so it must not fail validation.""" + config = { + "default": "mysql", + "connections": {"mysql": {"driver": "mysql", "host": "localhost", "database": "db"}}, + } + + manager = DatabaseManager(ConnectionFactory(), config) + + self.assertIsInstance(manager, DatabaseManager) + + def test_explicit_url_bypasses_driver_and_port_checks(self): + """A connection configured entirely via `url` skips field validation, + mirroring ConnectionFactory.build_url()'s own url passthrough.""" + config = { + "default": "oracle", + "connections": {"oracle": {"driver": "oracle", "url": "sqlite+aiosqlite:///:memory:"}}, + } + + manager = DatabaseManager(ConnectionFactory(), config) + + self.assertIsInstance(manager, DatabaseManager) + + def test_bad_connection_fails_at_provider_registration_not_first_query(self): + """End-to-end: DatabaseProvider.register() runs during Application + construction, so a bad config must raise while booting the app -- + before `db.connection()` is ever called.""" + with tempfile.TemporaryDirectory() as tmp: + with self.assertRaises(DriverNotFound): + Application( + base_path=Path(tmp), + env="testing", + providers=[ + ( + DatabaseProvider, + { + "default": "mssql", + "connections": {"mssql": {"driver": "mssql", "host": "localhost", "database": "db"}}, + }, + ) + ], + ) + + class TestDatabaseProviderWiring(unittest.TestCase): """DatabaseProvider.register() must produce a DatabaseManager whose connection() resolves cleanly — this is what `python artisan db:migrate` diff --git a/fastapi_startkit/tests/masoniteorm/models/test_model_attributes.py b/fastapi_startkit/tests/masoniteorm/models/test_model_attributes.py index b3957105..5311b37a 100644 --- a/fastapi_startkit/tests/masoniteorm/models/test_model_attributes.py +++ b/fastapi_startkit/tests/masoniteorm/models/test_model_attributes.py @@ -86,9 +86,10 @@ def test_connection_is_cached(self, db): assert conn1 is conn2 def test_connection_raises_for_missing_driver(self): - # make() now validates the driver before any engine is built, so an - # unsupported driver fails fast with a friendly framework error rather - # than a raw KeyError/ValueError from deep inside SQLAlchemy. + # DatabaseManager now validates every configured connection's driver at + # construction (app-boot) time, so an unsupported driver fails fast with + # a friendly framework error before any engine is ever built, rather than + # lazily on first use. factory = ConnectionFactory() bad_config = { "default": "mssql", @@ -96,9 +97,8 @@ def test_connection_raises_for_missing_driver(self): "mssql": {"driver": "mssql", "host": "localhost", "database": "db"}, }, } - dm = DatabaseManager(factory, bad_config) with pytest.raises(DriverNotFound, match="mssql"): - dm.connection("mssql") + DatabaseManager(factory, bad_config) # ---------------------------------------------------------------------------