From aec96872b211a2f79e3c1e130940d42567ab2296 Mon Sep 17 00:00:00 2001 From: Glyph Date: Thu, 28 Mar 2024 15:20:17 -0700 Subject: [PATCH 1/4] make the front page of the docs actually have some content This commit was sponsored by Hynek, Thomas Ballinger, Matt Campbell, and my other patrons. If you want to join them, you can support my work at https://glyph.im/patrons/. --- docs/codeexamples/userpost.py | 3 +- docs/index.rst | 67 +++++++++++++++++++++++++++++++++-- docs/introduction.rst | 65 --------------------------------- 3 files changed, 66 insertions(+), 69 deletions(-) delete mode 100644 docs/introduction.rst diff --git a/docs/codeexamples/userpost.py b/docs/codeexamples/userpost.py index e37604e..e2963a2 100644 --- a/docs/codeexamples/userpost.py +++ b/docs/codeexamples/userpost.py @@ -10,7 +10,8 @@ from twisted.python.failure import Failure from dbxs import accessor, many, one, query, statement -from dbxs.dbapi_async import adaptSynchronousDriver, transaction +from dbxs.adapters.dbapi_twisted import adaptSynchronousDriver +from dbxs.async_dbapi import transaction schema = """ diff --git a/docs/index.rst b/docs/index.rst index 7385f71..20b58dc 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -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 + `_, which presents an abstract + syntax tree, but maintains the semantics of SQL + +3. an *Object Relational Mapper*, like `SQLAlchemy ORM + `_. + +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 +`_. 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 ================== diff --git a/docs/introduction.rst b/docs/introduction.rst deleted file mode 100644 index dd8b56c..0000000 --- a/docs/introduction.rst +++ /dev/null @@ -1,65 +0,0 @@ -======================== -What is DBXS? -======================== - -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 - `_, which presents an abstract - syntax tree, but maintains the semantics of SQL - -3. an *Object Relational Mapper*, like `SQLAlchemy ORM - `_. - -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 -`_. 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 From aba4fdbe724f1e653511dedcfb94be220828e871 Mon Sep 17 00:00:00 2001 From: Glyph Date: Thu, 28 Mar 2024 15:56:05 -0700 Subject: [PATCH 2/4] implement basic repository This commit was sponsored by Steven S., Sergio Bost, James C Abel, and my other patrons. If you want to join them, you can support my work at https://glyph.im/patrons/. --- src/dbxs/__init__.py | 2 ++ src/dbxs/_repository.py | 64 ++++++++++++++++++++++++++++++++++++ src/dbxs/test/test_access.py | 25 ++++++++++++++ 3 files changed, 91 insertions(+) create mode 100644 src/dbxs/_repository.py diff --git a/src/dbxs/__init__.py b/src/dbxs/__init__.py index c1b1b8f..125f31a 100644 --- a/src/dbxs/__init__.py +++ b/src/dbxs/__init__.py @@ -15,6 +15,7 @@ query, statement, ) +from ._repository import repository __version__ = "0.0.5" @@ -25,6 +26,7 @@ "many", "maybe", "accessor", + "repository", "statement", "query", "ParamMismatch", diff --git a/src/dbxs/_repository.py b/src/dbxs/_repository.py new file mode 100644 index 0000000..6ffa597 --- /dev/null +++ b/src/dbxs/_repository.py @@ -0,0 +1,64 @@ +# -*- test-case-name: dbxs.test.test_access.AccessTestCase.test_repository -*- +""" +A repository combines a collection of accessors. +""" + +from __future__ import annotations + +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, eval_str=True) + accessors = {} + for name, parameter in sig.parameters.items(): # pragma: no branch + accessors[name] = accessor(parameter.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 diff --git a/src/dbxs/test/test_access.py b/src/dbxs/test/test_access.py index 20e0ce7..aa7686f 100644 --- a/src/dbxs/test/test_access.py +++ b/src/dbxs/test/test_access.py @@ -15,6 +15,7 @@ maybe, one, query, + repository, statement, ) from .._typing_compat import Protocol @@ -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. @@ -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) From d5a888b8648562d9ed8abddd58ab473efff33983 Mon Sep 17 00:00:00 2001 From: Glyph Date: Thu, 28 Mar 2024 16:09:07 -0700 Subject: [PATCH 3/4] backport of eval_str This commit was sponsored by zts, Jason Walker, Steven S., and my other patrons. If you want to join them, you can support my work at https://glyph.im/patrons/. --- .pre-commit-config.yaml | 1 - src/dbxs/_repository.py | 12 ++++++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index fd0b8eb..7c8325b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -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 diff --git a/src/dbxs/_repository.py b/src/dbxs/_repository.py index 6ffa597..649ee58 100644 --- a/src/dbxs/_repository.py +++ b/src/dbxs/_repository.py @@ -5,6 +5,7 @@ from __future__ import annotations +import sys from contextlib import asynccontextmanager from inspect import signature from typing import AsyncContextManager, AsyncIterator, Callable, TypeVar @@ -48,10 +49,17 @@ async def userAndPosts(pool: AsyncConnectable, id: UserID) -> str: # transaction commits here """ - sig = signature(repositoryType, eval_str=True) + sig = signature(repositoryType) accessors = {} for name, parameter in sig.parameters.items(): # pragma: no branch - accessors[name] = accessor(parameter.annotation) + 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]: From 3dcba8cf2601f0335bca9e506ff0fe28935ecc96 Mon Sep 17 00:00:00 2001 From: Glyph Date: Thu, 28 Mar 2024 16:15:14 -0700 Subject: [PATCH 4/4] update example to use repository rather than accessor This commit was sponsored by Matt Campbell, Sergio Bost, rockstar, and my other patrons. If you want to join them, you can support my work at https://glyph.im/patrons/. --- docs/codeexamples/userpost.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/docs/codeexamples/userpost.py b/docs/codeexamples/userpost.py index e2963a2..33bd1d5 100644 --- a/docs/codeexamples/userpost.py +++ b/docs/codeexamples/userpost.py @@ -9,17 +9,17 @@ from twisted.internet.interfaces import IReactorCore from twisted.python.failure import Failure -from dbxs import accessor, many, one, query, statement +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, @@ -100,7 +100,12 @@ async def makePostByUser( ... -posts = accessor(PostDB) +@dataclass +class BlogRepo: + posts: PostDB + + +blog = repository(BlogRepo) async def main() -> None: @@ -109,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