Skip to content
This repository was archived by the owner on Aug 19, 2025. It is now read-only.

Commit 4198ae2

Browse files
authored
Merge branch 'master' into aiopg
2 parents 9e58341 + 57197d7 commit 4198ae2

File tree

10 files changed

+137
-33
lines changed

10 files changed

+137
-33
lines changed

README.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ It allows you to make queries using the powerful [SQLAlchemy Core][sqlalchemy-co
1818
expression language, and provides support for PostgreSQL, MySQL, and SQLite.
1919

2020
Databases is suitable for integrating against any async Web framework, such as [Starlette][starlette],
21-
[Sanic][sanic], [Responder][responder], [Quart][quart], [aiohttp][aiohttp], [Tornado][tornado], [FastAPI][fastapi], or [Bocadillo][bocadillo].
21+
[Sanic][sanic], [Responder][responder], [Quart][quart], [aiohttp][aiohttp], [Tornado][tornado], or [FastAPI][fastapi].
2222

2323
**Documentation**: [https://www.encode.io/databases/](https://www.encode.io/databases/)
2424

@@ -107,4 +107,3 @@ for examples of how to start using databases together with SQLAlchemy core expre
107107
[aiohttp]: https://github.com/aio-libs/aiohttp
108108
[tornado]: https://github.com/tornadoweb/tornado
109109
[fastapi]: https://github.com/tiangolo/fastapi
110-
[bocadillo]: https://github.com/bocadilloproject/bocadillo

databases/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
from databases.core import Database, DatabaseURL
22

3-
__version__ = "0.2.3"
3+
__version__ = "0.2.6"
44
__all__ = ["Database", "DatabaseURL"]

databases/backends/mysql.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from sqlalchemy.sql import ClauseElement
1111
from sqlalchemy.types import TypeEngine
1212

13-
from databases.core import DatabaseURL
13+
from databases.core import LOG_EXTRA, DatabaseURL
1414
from databases.interfaces import ConnectionBackend, DatabaseBackend, TransactionBackend
1515

1616
logger = logging.getLogger("databases")
@@ -179,7 +179,8 @@ def _compile(
179179
compiled._textual_ordered_columns,
180180
)
181181

182-
logger.debug("Query: %s\nArgs: %s", compiled.string, args)
182+
query_message = compiled.string.replace(" \n", " ").replace("\n", " ")
183+
logger.debug("Query: %s Args: %s", query_message, repr(args), extra=LOG_EXTRA)
183184
return compiled.string, args, CompilationContext(execution_context)
184185

185186
@property

databases/backends/postgres.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from sqlalchemy.sql.schema import Column
1010
from sqlalchemy.types import TypeEngine
1111

12-
from databases.core import DatabaseURL
12+
from databases.core import LOG_EXTRA, DatabaseURL
1313
from databases.interfaces import ConnectionBackend, DatabaseBackend, TransactionBackend
1414

1515
logger = logging.getLogger("databases")
@@ -115,10 +115,10 @@ def __getitem__(self, key: typing.Any) -> typing.Any:
115115
return raw
116116

117117
def __iter__(self) -> typing.Iterator:
118-
return iter(self._column_map)
118+
return iter(self._row.keys())
119119

120120
def __len__(self) -> int:
121-
return len(self._column_map)
121+
return len(self._row)
122122

123123

124124
class PostgresConnection(ConnectionBackend):
@@ -192,7 +192,10 @@ def _compile(self, query: ClauseElement) -> typing.Tuple[str, list, tuple]:
192192
for key, val in compiled_params
193193
]
194194

195-
logger.debug("Query: %s\nArgs: %s", compiled_query, args)
195+
query_message = compiled_query.replace(" \n", " ").replace("\n", " ")
196+
logger.debug(
197+
"Query: %s Args: %s", query_message, repr(tuple(args)), extra=LOG_EXTRA
198+
)
196199
return compiled_query, args, compiled._result_columns
197200

198201
@property

databases/backends/sqlite.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from sqlalchemy.sql import ClauseElement
1010
from sqlalchemy.types import TypeEngine
1111

12-
from databases.core import DatabaseURL
12+
from databases.core import LOG_EXTRA, DatabaseURL
1313
from databases.interfaces import ConnectionBackend, DatabaseBackend, TransactionBackend
1414

1515
logger = logging.getLogger("databases")
@@ -154,7 +154,10 @@ def _compile(
154154
compiled._textual_ordered_columns,
155155
)
156156

157-
logger.debug("Query: %s\nArgs: %s", compiled.string, args)
157+
query_message = compiled.string.replace(" \n", " ").replace("\n", " ")
158+
logger.debug(
159+
"Query: %s Args: %s", query_message, repr(tuple(args)), extra=LOG_EXTRA
160+
)
158161
return compiled.string, args, CompilationContext(execution_context)
159162

160163
@property

databases/core.py

Lines changed: 55 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import asyncio
22
import functools
3+
import logging
34
import sys
45
import typing
56
from types import TracebackType
@@ -16,11 +17,33 @@
1617
else: # pragma: no cover
1718
from aiocontextvars import ContextVar
1819

20+
try: # pragma: no cover
21+
import click
22+
23+
# Extra log info for optional coloured terminal outputs.
24+
LOG_EXTRA = {
25+
"color_message": "Query: " + click.style("%s", bold=True) + " Args: %s"
26+
}
27+
CONNECT_EXTRA = {
28+
"color_message": "Connected to database " + click.style("%s", bold=True)
29+
}
30+
DISCONNECT_EXTRA = {
31+
"color_message": "Disconnected from database " + click.style("%s", bold=True)
32+
}
33+
except ImportError: # pragma: no cover
34+
LOG_EXTRA = {}
35+
CONNECT_EXTRA = {}
36+
DISCONNECT_EXTRA = {}
37+
38+
39+
logger = logging.getLogger("databases")
40+
1941

2042
class Database:
2143
SUPPORTED_BACKENDS = {
2244
"postgresql": "databases.backends.postgres:PostgresBackend",
2345
"postgresql+aiopg": "databases.backends.aiopg:AiopgBackend",
46+
"postgres": "databases.backends.postgres:PostgresBackend",
2447
"mysql": "databases.backends.mysql:MySQLBackend",
2548
"sqlite": "databases.backends.sqlite:SQLiteBackend",
2649
}
@@ -51,23 +74,27 @@ def __init__(
5174
self._global_connection = None # type: typing.Optional[Connection]
5275
self._global_transaction = None # type: typing.Optional[Transaction]
5376

54-
if self._force_rollback:
55-
self._global_connection = Connection(self._backend)
56-
self._global_transaction = self._global_connection.transaction(
57-
force_rollback=True
58-
)
59-
6077
async def connect(self) -> None:
6178
"""
6279
Establish the connection pool.
6380
"""
6481
assert not self.is_connected, "Already connected."
6582

6683
await self._backend.connect()
84+
logger.info(
85+
"Connected to database %s", self.url.obscure_password, extra=CONNECT_EXTRA
86+
)
6787
self.is_connected = True
6888

6989
if self._force_rollback:
70-
assert self._global_transaction is not None
90+
assert self._global_connection is None
91+
assert self._global_transaction is None
92+
93+
self._global_connection = Connection(self._backend)
94+
self._global_transaction = self._global_connection.transaction(
95+
force_rollback=True
96+
)
97+
7198
await self._global_transaction.__aenter__()
7299

73100
async def disconnect(self) -> None:
@@ -77,10 +104,20 @@ async def disconnect(self) -> None:
77104
assert self.is_connected, "Already disconnected."
78105

79106
if self._force_rollback:
107+
assert self._global_connection is not None
80108
assert self._global_transaction is not None
109+
81110
await self._global_transaction.__aexit__()
82111

112+
self._global_transaction = None
113+
self._global_connection = None
114+
83115
await self._backend.disconnect()
116+
logger.info(
117+
"Disconnected from database %s",
118+
self.url.obscure_password,
119+
extra=DISCONNECT_EXTRA,
120+
)
84121
self.is_connected = False
85122

86123
async def __aenter__(self) -> "Database":
@@ -367,7 +404,10 @@ def netloc(self) -> typing.Optional[str]:
367404

368405
@property
369406
def database(self) -> str:
370-
return self.components.path.lstrip("/")
407+
path = self.components.path
408+
if path.startswith("/"):
409+
path = path[1:]
410+
return path
371411

372412
@property
373413
def options(self) -> dict:
@@ -414,14 +454,17 @@ def replace(self, **kwargs: typing.Any) -> "DatabaseURL":
414454
components = self.components._replace(**kwargs)
415455
return self.__class__(components.geturl())
416456

457+
@property
458+
def obscure_password(self) -> str:
459+
if self.password:
460+
return self.replace(password="********")._url
461+
return self._url
462+
417463
def __str__(self) -> str:
418464
return self._url
419465

420466
def __repr__(self) -> str:
421-
url = str(self)
422-
if self.password:
423-
url = str(self.replace(password="********"))
424-
return f"{self.__class__.__name__}({repr(url)})"
467+
return f"{self.__class__.__name__}({repr(self.obscure_password)})"
425468

426469
def __eq__(self, other: typing.Any) -> bool:
427470
return str(self) == str(other)

docs/index.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ It allows you to make queries using the powerful [SQLAlchemy Core][sqlalchemy-co
1818
expression language, and provides support for PostgreSQL, MySQL, and SQLite.
1919

2020
Databases is suitable for integrating against any async Web framework, such as [Starlette][starlette],
21-
[Sanic][sanic], [Responder][responder], [Quart][quart], [aiohttp][aiohttp], [Tornado][tornado], [FastAPI][fastapi], or [Bocadillo][bocadillo].
21+
[Sanic][sanic], [Responder][responder], [Quart][quart], [aiohttp][aiohttp], [Tornado][tornado], or [FastAPI][fastapi].
2222

2323
**Community**: [https://discuss.encode.io/c/databases](https://discuss.encode.io/c/databases)
2424

@@ -105,4 +105,3 @@ for examples of how to start using databases together with SQLAlchemy core expre
105105
[aiohttp]: https://github.com/aio-libs/aiohttp
106106
[tornado]: https://github.com/tornadoweb/tornado
107107
[fastapi]: https://github.com/tiangolo/fastapi
108-
[bocadillo]: https://github.com/bocadilloproject/bocadillo

tests/test_database_url.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,3 +51,9 @@ def test_replace_database_url_components():
5151
new = u.replace(database="test_" + u.database)
5252
assert new.database == "test_mydatabase"
5353
assert str(new) == "sqlite:///test_mydatabase"
54+
55+
u = DatabaseURL("sqlite:////absolute/path")
56+
assert u.database == "/absolute/path"
57+
new = u.replace(database=u.database + "_test")
58+
assert new.database == "/absolute/path_test"
59+
assert str(new) == "sqlite:////absolute/path_test"

tests/test_databases.py

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ def async_adapter(wrapped_func):
104104

105105
@functools.wraps(wrapped_func)
106106
def run_sync(*args, **kwargs):
107-
loop = asyncio.get_event_loop()
107+
loop = asyncio.new_event_loop()
108108
task = wrapped_func(*args, **kwargs)
109109
return loop.run_until_complete(task)
110110

@@ -770,6 +770,34 @@ async def db_lookup():
770770
await asyncio.gather(db_lookup(), db_lookup())
771771

772772

773+
@pytest.mark.parametrize("database_url", DATABASE_URLS)
774+
def test_global_connection_is_initialized_lazily(database_url):
775+
"""
776+
Ensure that global connection is initialized at latest possible time
777+
so it's _query_lock will belong to same event loop that async_adapter has
778+
initialized.
779+
780+
See https://github.com/encode/databases/issues/157 for more context.
781+
"""
782+
783+
database_url = DatabaseURL(database_url)
784+
if database_url.dialect != "postgresql":
785+
pytest.skip("Test requires `pg_sleep()`")
786+
787+
database = Database(database_url, force_rollback=True)
788+
789+
@async_adapter
790+
async def run_database_queries():
791+
async with database:
792+
793+
async def db_lookup():
794+
await database.fetch_one("SELECT pg_sleep(1)")
795+
796+
await asyncio.gather(db_lookup(), db_lookup())
797+
798+
run_database_queries()
799+
800+
773801
@pytest.mark.parametrize("database_url", DATABASE_URLS)
774802
@async_adapter
775803
async def test_iterate_outside_transaction_with_values(database_url):
@@ -820,3 +848,25 @@ async def test_iterate_outside_transaction_with_temp_table(database_url):
820848
iterate_results.append(result)
821849

822850
assert len(iterate_results) == 5
851+
852+
853+
@pytest.mark.parametrize("database_url", DATABASE_URLS)
854+
@pytest.mark.parametrize("select_query", [notes.select(), "SELECT * FROM notes"])
855+
@async_adapter
856+
async def test_column_names(database_url, select_query):
857+
"""
858+
Test that column names are exposed correctly through `.keys()` on each row.
859+
"""
860+
async with Database(database_url) as database:
861+
async with database.transaction(force_rollback=True):
862+
# insert values
863+
query = notes.insert()
864+
values = {"text": "example1", "completed": True}
865+
await database.execute(query, values)
866+
# fetch results
867+
results = await database.fetch_all(query=select_query)
868+
assert len(results) == 1
869+
870+
assert sorted(results[0].keys()) == ["completed", "id", "text"]
871+
assert results[0]["text"] == "example1"
872+
assert results[0]["completed"] == True

tests/test_importer.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,28 +4,28 @@
44

55

66
def test_invalid_format():
7-
with pytest.raises(ImportFromStringError) as exc:
7+
with pytest.raises(ImportFromStringError) as exc_info:
88
import_from_string("example:")
99
expected = 'Import string "example:" must be in format "<module>:<attribute>".'
10-
assert expected in str(exc)
10+
assert exc_info.match(expected)
1111

1212

1313
def test_invalid_module():
14-
with pytest.raises(ImportFromStringError) as exc:
14+
with pytest.raises(ImportFromStringError) as exc_info:
1515
import_from_string("module_does_not_exist:myattr")
1616
expected = 'Could not import module "module_does_not_exist".'
17-
assert expected in str(exc)
17+
assert exc_info.match(expected)
1818

1919

2020
def test_invalid_attr():
21-
with pytest.raises(ImportFromStringError) as exc:
21+
with pytest.raises(ImportFromStringError) as exc_info:
2222
import_from_string("tempfile:attr_does_not_exist")
2323
expected = 'Attribute "attr_does_not_exist" not found in module "tempfile".'
24-
assert expected in str(exc)
24+
assert exc_info.match(expected)
2525

2626

2727
def test_internal_import_error():
28-
with pytest.raises(ImportError) as exc:
28+
with pytest.raises(ImportError):
2929
import_from_string("tests.importer.raise_import_error:myattr")
3030

3131

0 commit comments

Comments
 (0)