Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

implement a "repository" convenience function for collecting a bunch of accessors and transaction management #92

Merged
merged 4 commits into from
Mar 28, 2024
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
1 change: 0 additions & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,6 @@ repos:
hooks:
- id: python-check-blanket-noqa
- id: python-check-blanket-type-ignore
- id: python-no-eval
- id: rst-backticks
- id: rst-directive-colons
- id: rst-inline-touching-normal
Expand Down
21 changes: 13 additions & 8 deletions docs/codeexamples/userpost.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,17 @@
from twisted.internet.interfaces import IReactorCore
from twisted.python.failure import Failure

from dbxs import accessor, many, one, query, statement
from dbxs.dbapi_async import adaptSynchronousDriver, transaction
from dbxs import many, one, query, repository, statement
from dbxs.adapters.dbapi_twisted import adaptSynchronousDriver
from dbxs.async_dbapi import transaction


schema = """
CREATE TABLE user (
CREATE TABLE IF NOT EXISTS user (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL
);
CREATE TABLE post (
CREATE TABLE IF NOT EXISTS post (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created TIMESTAMP NOT NULL,
content TEXT NOT NULL,
Expand Down Expand Up @@ -99,7 +100,12 @@ async def makePostByUser(
...


posts = accessor(PostDB)
@dataclass
class BlogRepo:
posts: PostDB


blog = repository(BlogRepo)


async def main() -> None:
Expand All @@ -108,9 +114,8 @@ async def main() -> None:
for expr in schema.split(";"):
await cur.execute(expr)

async with transaction(asyncDriver) as c:
poster = posts(c)
b = await poster.createUser("bob")
async with blog(asyncDriver) as db:
b = await db.posts.createUser("bob")
await b.post("a post")
await b.post("another post")
post: Post
Expand Down
67 changes: 64 additions & 3 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,75 @@
DataBase Acc(X)esS
================================

DBXS is a **query organizer**.

It provides a simple structure for collecting a group of queries into a set of
methods which can be executed against a database, and to describe a way to
process the results of those queries into a data structure.

The Problem
===========

In a programming language, there are several interfaces by which one might
access a database. Ordered from low-level to high-level, they are:

1. a *database driver*, which presents an interface that accepts strings of SQL
and parameters, and allows you to access a database.

2. an *SQL expression model*, like `SQLAlchemy Core
<https://docs.sqlalchemy.org/en/20/core/>`_, which presents an abstract
syntax tree, but maintains the semantics of SQL

3. an *Object Relational Mapper*, like `SQLAlchemy ORM
<https://docs.sqlalchemy.org/en/20/orm/index.html>`_.

While ORMs and expression models can be powerful tools, they require every
developer using them to translate any database operations from the SQL that
they already know to a new, slightly different language.

However, using a database driver directly can be error-prone. One of the
biggest problems with databases today remains `SQL injection
<https://owasp.org/Top10/A03_2021-Injection/>`_. And when you are passing
strings directly do a database driver, even if you know that you need to be
careful to pass all your inputs as parameters, there is no structural mechanism
in your code to prevent you from forgetting that for a moment and accidentally
formatting a string.

Plus, it can be difficult to see all the ways that your database is being
queried if they're spread out throughout your code. This can make it hard to
see, for example, what indexes might be useful to create, without combing
through database logs.


DBXS's solution
===============

To access a database with DBXS, you write a Python protocol that describes all
of the queries that your database can perform. Let's begin with the database
interface for a very simple blog, where users can sign up, make posts, and then
read their posts.

.. literalinclude:: codeexamples/no_db_yet_userpost.py

We have a ``User`` record class, a ``Post`` record class, and a ``PostDB``
protocol that allows us to create users, list posts for users, and make posts.

First, let's fill out that ``User`` class. We'll make it a ``dataclass``.
Record classes will need some way to refer back to their database, so it will
have a ``PostDB`` as its first attribute, then an integer ``id`` and a string
``name``.

.. literalinclude:: codeexamples/userpost.py
:start-after: # user attributes
:end-before: # end user attributes


.. toctree::
:maxdepth: 2
:caption: Contents:
:caption: Table of Contents:

introduction
howto


Indices and tables
==================

Expand Down
65 changes: 0 additions & 65 deletions docs/introduction.rst

This file was deleted.

2 changes: 2 additions & 0 deletions src/dbxs/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
query,
statement,
)
from ._repository import repository


__version__ = "0.0.5"
Expand All @@ -25,6 +26,7 @@
"many",
"maybe",
"accessor",
"repository",
"statement",
"query",
"ParamMismatch",
Expand Down
72 changes: 72 additions & 0 deletions src/dbxs/_repository.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# -*- test-case-name: dbxs.test.test_access.AccessTestCase.test_repository -*-
"""
A repository combines a collection of accessors.
"""

from __future__ import annotations

import sys
from contextlib import asynccontextmanager
from inspect import signature
from typing import AsyncContextManager, AsyncIterator, Callable, TypeVar

from ._access import accessor
from .async_dbapi import AsyncConnectable, transaction


T = TypeVar("T")


def repository(
repositoryType: type[T],
) -> Callable[[AsyncConnectable], AsyncContextManager[T]]:
"""
A L{repository} combines management of a transaction with management of a
"repository", which is a collection of L{accessor}s and a contextmanager
that manages a transaction. This is easier to show with an example than a
description::

class Users(Protocol):
@query(sql="...", load=one(User))
def getUserByID(self, id: UserID) -> User: ...

class Posts(Protocol):
@query(sql="...", load=many(Post))
def getPostsFromUser(self, id: UserID) -> AsyncIterator[Posts]: ...

@dataclass
class BlogDB:
users: Users
posts: Posts

blogRepository = repository(BlogDB)

# ...
async def userAndPosts(pool: AsyncConnectable, id: UserID) -> str:
async with blogRepository(pool) as blog:
user = await blog.users.getUserByID(id)
posts = await blog.posts.getPostsFromUser(posts)
# transaction commits here
"""

sig = signature(repositoryType)
accessors = {}
for name, parameter in sig.parameters.items(): # pragma: no branch
annotation = parameter.annotation
# It would be nicer to do this with signature(..., eval_str=True), but
# that's not available until we require python>=3.10
if isinstance(annotation, str): # pragma: no branch
annotation = eval(
annotation, sys.modules[repositoryType.__module__].__dict__
)
accessors[name] = accessor(annotation)

@asynccontextmanager
async def transactify(acxn: AsyncConnectable) -> AsyncIterator[T]:
kw = {}
async with transaction(acxn) as aconn:
for name in accessors: # pragma: no branch
kw[name] = accessors[name](aconn)
yield repositoryType(**kw)

return transactify
25 changes: 25 additions & 0 deletions src/dbxs/test/test_access.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
maybe,
one,
query,
repository,
statement,
)
from .._typing_compat import Protocol
Expand Down Expand Up @@ -119,9 +120,24 @@ async def newReturnFoo(self, baz: int) -> Foo:
"""


class OtherAccessPattern(Protocol):
@query(sql="select {value} + 1", load=one(lambda db, x: x))
async def addOneTo(self, value: int) -> int:
...


accessFoo = accessor(FooAccessPattern)


@dataclass
class ExampleRepository:
foo: FooAccessPattern
other: OtherAccessPattern


exampleRepo = repository(ExampleRepository)


async def schemaAndData(c: AsyncConnection) -> None:
"""
Create the schema for 'foo' and insert some sample data.
Expand Down Expand Up @@ -300,3 +316,12 @@ async def test_statementWithResultIsError(self, pool: MemoryPool) -> None:
with self.assertRaises(TooManyResults) as tmr:
await db.oopsQueryNotStatement()
self.assertIn("should not return", str(tmr.exception))

@immediateTest()
async def test_repository(self, pool: MemoryPool) -> None:
"""
Test constructing a repository.
"""
async with exampleRepo(pool.connectable) as repo:
self.assertEqual(await repo.foo.echoValue(7), "7")
self.assertEqual(await repo.other.addOneTo(3), 4)
Loading