Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from sqlalchemy.pool import NullPool
from sqlalchemy.ext.asyncio import create_async_engine, AsyncEngine

from fastapi_startkit.exceptions.exceptions import DriverNotFound
from fastapi_startkit.masoniteorm.connections.connection import Connection
from fastapi_startkit.masoniteorm.connections.sqlite_connection import SQliteConnection
from fastapi_startkit.masoniteorm.connections.postgres_connection import (
Expand All @@ -19,13 +20,33 @@ class ConnectionFactory:
"postgres": "postgresql+asyncpg",
}

# Sensible per-driver defaults so a missing/empty "port" no longer surfaces as a
# raw SQLAlchemy `ValueError: invalid literal for int()` at engine-creation time.
DEFAULT_PORTS = {
"mysql": 3306,
"postgres": 5432,
}

CONNECTIONS = {
"sqlite": SQliteConnection,
"mysql": MySQLConnection,
"postgres": PostgresConnection,
}

@classmethod
def _unsupported_driver_error(cls, driver: Any) -> DriverNotFound:
supported = ", ".join(sorted(cls.DRIVER_URLS))
return DriverNotFound(f"Unsupported database driver {driver!r}. Supported drivers are: {supported}.")

@classmethod
def build_url(cls, config: dict) -> str:
if url := config.get("url"):
return str(url)

driver = config["driver"]
scheme = cls.DRIVER_URLS[driver]
scheme = cls.DRIVER_URLS.get(driver)
if scheme is None:
raise cls._unsupported_driver_error(driver)
db = config.get("database", "")

if driver == "sqlite":
Expand All @@ -34,7 +55,7 @@ def build_url(cls, config: dict) -> str:
user = config.get("username", "")
pwd = config.get("password", "")
host = config.get("host", "localhost")
port = config.get("port", "")
port = config.get("port") or cls.DEFAULT_PORTS[driver]
return f"{scheme}://{user}:{pwd}@{host}:{port}/{db}"

@classmethod
Expand All @@ -50,15 +71,11 @@ def create_engine(cls, cfg: dict) -> AsyncEngine:
kwargs["poolclass"] = StaticPool
return create_async_engine(url, **kwargs)

def make(self, config: dict, name: str) -> type[Connection]:
engine = self.create_engine(config)
def make(self, config: dict, name: str) -> Connection:
driver = config["driver"]
match driver:
case "sqlite":
return SQliteConnection(engine, config)
case "postgres":
return PostgresConnection(engine, config)
case "mysql":
return MySQLConnection(engine, config)
connection_class = type(self).CONNECTIONS.get(driver)
if connection_class is None:
raise type(self)._unsupported_driver_error(driver)

raise ValueError(f"Unsupported driver: {driver}")
engine = self.create_engine(config)
return connection_class(engine, config)
99 changes: 99 additions & 0 deletions fastapi_startkit/tests/masoniteorm/config/test_db_url.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import unittest

from fastapi_startkit.exceptions.exceptions import DriverNotFound
from fastapi_startkit.masoniteorm.connections.factory import ConnectionFactory


Expand Down Expand Up @@ -76,3 +77,101 @@ def test_direct_url_passthrough_takes_precedence(self):
}
url = ConnectionFactory.build_url(config)
self.assertEqual(url, "mysql+aiomysql://admin:pw@prod-host:3306/live")


class TestConnectionFactoryMissingPort(unittest.TestCase):
"""A missing/empty port must fall back to a sane per-driver default instead of
producing a URL with a bare trailing colon that crashes at engine creation with
`ValueError: invalid literal for int() with base 10: ''`."""

def test_mysql_missing_port_uses_default_3306(self):
config = {
"driver": "mysql",
"host": "localhost",
"database": "mydb",
"username": "root",
"password": "secret",
}
url = ConnectionFactory.build_url(config)
self.assertEqual(url, "mysql+aiomysql://root:secret@localhost:3306/mydb")

def test_postgres_missing_port_uses_default_5432(self):
config = {
"driver": "postgres",
"host": "db.example.com",
"database": "mydb",
"username": "user",
"password": "pass",
}
url = ConnectionFactory.build_url(config)
self.assertEqual(url, "postgresql+asyncpg://user:pass@db.example.com:5432/mydb")

def test_mysql_empty_string_port_uses_default(self):
config = {
"driver": "mysql",
"host": "localhost",
"port": "",
"database": "mydb",
"username": "root",
"password": "secret",
}
url = ConnectionFactory.build_url(config)
self.assertEqual(url, "mysql+aiomysql://root:secret@localhost:3306/mydb")

def test_postgres_empty_string_port_uses_default(self):
config = {
"driver": "postgres",
"host": "localhost",
"port": "",
"database": "mydb",
"username": "user",
"password": "pass",
}
url = ConnectionFactory.build_url(config)
self.assertEqual(url, "postgresql+asyncpg://user:pass@localhost:5432/mydb")

def test_explicit_port_is_preserved(self):
"""An explicitly configured port must win over the default (happy path)."""
config = {
"driver": "postgres",
"host": "localhost",
"port": 6543,
"database": "mydb",
"username": "user",
"password": "pass",
}
url = ConnectionFactory.build_url(config)
self.assertEqual(url, "postgresql+asyncpg://user:pass@localhost:6543/mydb")


class TestConnectionFactoryUnsupportedDriver(unittest.TestCase):
"""An unknown driver must raise a friendly framework error, not a raw KeyError."""

def test_build_url_unknown_driver_raises_driver_not_found(self):
with self.assertRaises(DriverNotFound) as ctx:
ConnectionFactory.build_url({"driver": "oracle", "database": "mydb"})
message = str(ctx.exception)
self.assertIn("oracle", message)
# The message should guide the user toward the supported drivers.
self.assertIn("sqlite", message)
self.assertIn("mysql", message)
self.assertIn("postgres", message)

def test_build_url_does_not_raise_raw_keyerror(self):
with self.assertRaises(DriverNotFound):
ConnectionFactory.build_url({"driver": "cassandra"})

def test_make_unknown_driver_raises_driver_not_found(self):
"""make()'s driver check is reachable and fires before any engine is built."""
with self.assertRaises(DriverNotFound):
ConnectionFactory().make({"driver": "oracle", "database": "mydb"}, "oracle")

def test_url_passthrough_bypasses_driver_validation(self):
"""The documented `url` stopgap short-circuits field assembly entirely, so an
unknown driver name is irrelevant when a full url is supplied."""
config = {
"driver": "oracle",
"url": "sqlite+aiosqlite:///db.sqlite3",
}
url = ConnectionFactory.build_url(config)
self.assertEqual(url, "sqlite+aiosqlite:///db.sqlite3")
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import pendulum
import pytest
from unittest.mock import MagicMock, patch

from fastapi_startkit.carbon import Carbon
from fastapi_startkit.exceptions.exceptions import DriverNotFound
from fastapi_startkit.masoniteorm.models.fields import DateTimeField
from fastapi_startkit.masoniteorm.connections.factory import ConnectionFactory
from fastapi_startkit.masoniteorm.connections.manager import DatabaseManager
Expand Down Expand Up @@ -86,8 +86,9 @@ def test_connection_is_cached(self, db):
assert conn1 is conn2

def test_connection_raises_for_missing_driver(self):
# create_engine is called before the driver switch, so mock it to isolate
# the "Unsupported driver" branch in ConnectionFactory.make().
# 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.
factory = ConnectionFactory()
bad_config = {
"default": "mssql",
Expand All @@ -96,9 +97,8 @@ def test_connection_raises_for_missing_driver(self):
},
}
dm = DatabaseManager(factory, bad_config)
with patch.object(ConnectionFactory, "create_engine", return_value=MagicMock()):
with pytest.raises(ValueError, match="Unsupported driver"):
dm.connection("mssql")
with pytest.raises(DriverNotFound, match="mssql"):
dm.connection("mssql")


# ---------------------------------------------------------------------------
Expand Down
Loading