Skip to content

Commit ca2a517

Browse files
committed
LocalConnectionFactory minor cleanup and more tests. #319 #375
1 parent dff6d19 commit ca2a517

File tree

2 files changed

+64
-15
lines changed

2 files changed

+64
-15
lines changed

src/reader/_storage/_sqlite_utils.py

Lines changed: 16 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -412,6 +412,9 @@ class UsageError(DBError):
412412
display_name = "usage error"
413413

414414

415+
TEMPORARY_DB_PATHS = {':memory:', ''}
416+
417+
415418
class LocalConnectionFactory:
416419
"""Maintain a set of connections to the same database, one per thread.
417420
@@ -450,8 +453,6 @@ def __init__(
450453
self.kwargs = kwargs
451454
if kwargs.get('uri'): # pragma: no cover
452455
raise NotImplementedError("is_private() does not work for uri=True")
453-
if path.startswith(':file:'): # pragma: no cover
454-
raise NotImplementedError("file: connections are not supported")
455456
self.kwargs['uri'] = True
456457
self.attached: dict[str, str] = {}
457458
self._local = _LocalConnectionFactoryState()
@@ -595,25 +596,27 @@ def attach(self, name: str, path: str) -> None:
595596
if name in self.attached: # pragma: no cover
596597
raise ValueError(f"database already attached: {name!r}")
597598

598-
uri_path = self._make_uri(path)
599-
self.attached[name] = uri_path
599+
self.attached[name] = path
600600
db = self._local.db
601601
assert db is not None
602-
self._attach(db, name, uri_path)
602+
self._attach(db, name, path)
603603

604604
def _attach(self, db: sqlite3.Connection, name: str, path: str) -> None:
605-
db.execute("ATTACH DATABASE ? AS ?;", (path, name))
605+
db.execute("ATTACH DATABASE ? AS ?;", (self._make_uri(path), name))
606606

607607
def is_private(self) -> bool:
608608
return self._is_private(self.path)
609609

610610
def _make_uri(self, path: str) -> str:
611-
# keeping sqlite special names intact
612-
if path in (':memory:', ''):
613-
return path
614-
# make the path full, otherwise it cannot be turned into URI
615-
abs_path = Path(path).resolve(strict=False)
616-
uri = abs_path.as_uri()
611+
# SQLite allows relative paths by skipping the // after file:,
612+
# but pathlib.Path.as_uri() does not support this;
613+
# however, temporary databases *must* be relative
614+
if path in TEMPORARY_DB_PATHS:
615+
# arbitrary paths would need to quote ? to avoid qs injection
616+
uri = f"file:{path}"
617+
else:
618+
# bypasses SQLite's resolution of relative paths, should be fine
619+
uri = Path(path).absolute().as_uri()
617620

618621
if self.read_only:
619622
uri += "?mode=ro"
@@ -637,7 +640,7 @@ def _is_private(path: str) -> bool:
637640
https://www.sqlite.org/uri.html
638641
639642
"""
640-
return path in [':memory:', '']
643+
return path in TEMPORARY_DB_PATHS
641644

642645

643646
class _LocalConnectionFactoryState(threading.local):

tests/test_reader_context.py

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -332,11 +332,57 @@ def test_read_only(make_reader, db_path, parser):
332332
immutable_reader.get_feed(feed)
333333
assert len(list(immutable_reader.search_entries('one'))) >= 1
334334

335-
with pytest.raises(StorageError):
335+
with pytest.raises(StorageError) as excinfo:
336336
immutable_reader.add_feed('http://another.feed')
337+
assert 'readonly' in str(excinfo.value)
337338

338339
list(immutable_reader.search_entries('one'))
339-
with pytest.raises(SearchError):
340+
with pytest.raises(SearchError) as excinfo:
340341
immutable_reader.update_search()
342+
assert 'readonly' in str(excinfo.value)
341343

342344
assert len(list(immutable_reader.search_entries('two'))) == 0
345+
346+
347+
PATHS_RELATIVE = [
348+
'db.sqlite',
349+
'./db.sqlite',
350+
'file:db.sqlite',
351+
'db.sqlite?mode=injection',
352+
]
353+
354+
355+
@pytest.mark.parametrize('path', PATHS_RELATIVE)
356+
@pytest.mark.parametrize('absolute', [False, True])
357+
def test_paths_shared(make_reader, monkeypatch, tmp_path, path, absolute):
358+
monkeypatch.chdir(tmp_path)
359+
360+
# shouldn't fail
361+
make_reader(str(tmp_path / path) if absolute else path)
362+
363+
# we created the right file
364+
assert (tmp_path / path).exists()
365+
366+
367+
# test_paths_private covered by the other *_private tests
368+
369+
370+
@pytest.mark.parametrize('path', PATHS_RELATIVE)
371+
def test_paths_read_only_shared(make_reader, monkeypatch, tmp_path, path):
372+
monkeypatch.chdir(tmp_path)
373+
374+
# can't open because database doesn't exist
375+
with pytest.raises(StorageError):
376+
make_reader(path, read_only=True)
377+
378+
make_reader(path)
379+
380+
# shouldn't fail if database already exists
381+
reader = make_reader(path, read_only=True)
382+
383+
384+
@pytest.mark.parametrize('path', PATHS_PRIVATE)
385+
def test_paths_read_only_private(make_reader, path):
386+
# can open, but can't create tables etc. because read only
387+
with pytest.raises(StorageError):
388+
make_reader(path, read_only=True)

0 commit comments

Comments
 (0)