From ecc17b788a337ed2dd294d70905dbc227ef535a4 Mon Sep 17 00:00:00 2001 From: Price Hiller Date: Tue, 5 Oct 2021 05:14:45 -0500 Subject: [PATCH] Initial commit --- .gitignore | 4 ++ Database/__init__.py | 11 +++++ Database/__main__.py | 78 ++++++++++++++++++++++++++++++++ Database/base.py | 70 +++++++++++++++++++++++++++++ Database/database.py | 105 +++++++++++++++++++++++++++++++++++++++++++ setup.py | 12 +++++ 6 files changed, 280 insertions(+) create mode 100644 .gitignore create mode 100644 Database/__init__.py create mode 100644 Database/__main__.py create mode 100644 Database/base.py create mode 100644 Database/database.py create mode 100644 setup.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b4cf652 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.idea/ +.venv/ +venv/ +*__pycache__* \ No newline at end of file diff --git a/Database/__init__.py b/Database/__init__.py new file mode 100644 index 0000000..9dd289f --- /dev/null +++ b/Database/__init__.py @@ -0,0 +1,11 @@ +""" +See __main__.py for an example, further documentation can be found within both base.py and database.py +""" + +from .base import Base +from .database import Database + +__all__ = [ + 'Base', + 'database' +] diff --git a/Database/__main__.py b/Database/__main__.py new file mode 100644 index 0000000..5ff6f70 --- /dev/null +++ b/Database/__main__.py @@ -0,0 +1,78 @@ +import asyncio + +import sqlalchemy +from sqlalchemy import orm as sqlalchemy_orm +from sqlalchemy import future as sqlalchemy_future + +from Database.base import Base +from Database.database import Database + + +class A(Base): + __tablename__ = "a" + + data = sqlalchemy.Column(sqlalchemy.String) + create_date = sqlalchemy.Column(sqlalchemy.DateTime, server_default=sqlalchemy.func.now()) + bs = sqlalchemy_orm.relationship("B") + + # required in order to access columns with server defaults + # or SQL expression defaults, subsequent to a flush, without + # triggering an expired load + __mapper_args__ = {"eager_defaults": True} + + +class B(Base): + __tablename__ = "b" + a_id = sqlalchemy.Column(sqlalchemy.ForeignKey("a.id")) + data = sqlalchemy.Column(sqlalchemy.String) + + +async def main(): + db = Database( + "postgresql://localhost:5432/testing", + ) + + await db.drop_all() + await db.create_all() + + # expire_on_commit=False will prevent attributes from being expired + # after commit. + + async with db.async_session() as session: + async with session.begin(): + session.add_all( + [ + A(bs=[B(), B()], data="a1"), + A(bs=[B()], data="a2"), + A(bs=[B(), B()], data="a3"), + ] + ) + + stmt = sqlalchemy_future.select(A).options(sqlalchemy_orm.selectinload(A.bs)) + + result = await session.execute(stmt) + + for a1 in result.scalars(): + print(a1) + print(f"created at: {a1.create_date}") + for b1 in a1.bs: + print(b1) + + result = await session.execute(sqlalchemy_future.select(A).order_by(A.id)) + + a1 = result.scalars().first() + + a1.data = "new data" + + await session.commit() + + # access attribute subsequent to commit; this is what + # expire_on_commit=False allows + print(a1.data) + + # for AsyncEngine created in function scope, close and + # clean-up pooled connections + await db.async_engine.dispose() + + +asyncio.run(main()) diff --git a/Database/base.py b/Database/base.py new file mode 100644 index 0000000..354d003 --- /dev/null +++ b/Database/base.py @@ -0,0 +1,70 @@ +import sqlalchemy.orm +from sqlalchemy.dialects.postgresql import UUID + + +@sqlalchemy.orm.as_declarative() +class Base: + """ + Standard base class to define a table in a postgresql database + + Default Columns: + -> id + - Defines a UUID generated via postgresql's in-built "uuid_generate_v4" function, + requires the "uuid-ossp" to be installed to the given database. The extension can be installed via + "CREATE EXTENSION IF NOT EXISTS "uuid-ossp";" + - May never be null + -> creation + - Defines the creation date in ISO 8601 format based on the sqlalchemy DateTime class and generated by the + database using func.now() + - May never be null + -> Modification + - Defines a ISO 8601 timestamp that is updated anytime the data in a column is modified for a given row + - Can be null + + Usage: + This class, "Base", must be inherited by subclasses to define tables within a database. A subclass must define + a single string with a variable name of "__tablename__" which defines the actual table name within the database. + + -> Example Implementation + >>> class SomeTable(Base): + >>> __tablename__ = "Some Table" + >>> data = sqlalchemy.Column(sqlalchemy.String) + """ + + __table__: sqlalchemy.Table + + # This ID column expects the extension "uuid-ossp" to be installed on the postgres DB + # Can be done via "CREATE EXTENSION IF NOT EXISTS "uuid-ossp";" + id = sqlalchemy.Column( + UUID, + default=sqlalchemy.text("uuid_generate_v4()"), + primary_key=True, + ) + + creation: sqlalchemy.Column = sqlalchemy.Column( + sqlalchemy.DateTime(timezone=True), + key="creation", + name="creation", + index=True, + quote=True, + unique=False, + default=None, + nullable=False, + primary_key=False, + autoincrement=False, + server_default=sqlalchemy.func.now(), + ) + + modification: sqlalchemy.Column = sqlalchemy.Column( + sqlalchemy.DateTime(timezone=True), + key="Modification", + name="modification", + index=True, + quote=True, + unique=False, + default=None, + nullable=True, + onupdate=sqlalchemy.func.now(), + primary_key=False, + autoincrement=False + ) diff --git a/Database/database.py b/Database/database.py new file mode 100644 index 0000000..cadfc02 --- /dev/null +++ b/Database/database.py @@ -0,0 +1,105 @@ +import sqlalchemy.orm +import sqlalchemy.ext.asyncio + +from .base import Base + + +class Database: + """ + A class that is a composition of several other classes useful for accessing and manipulating a Postgresql database + asynchronously. + + Usage: + Database must first be initialized with a given postgresql connection url like so: + >>> db = Database("postgresql://localhost:5432/postgres") + After Database has been initialized two important objects that are composed into Database become available: + -> async_engine + - Relevant for handling the creation and deletion of many tables at once, for example: + >>> async with self.async_engine.begin() as conn: + >>> await conn.run_sync(self.Base.metadata.drop_all) + >>> await conn.run_sync(self.Base.metadata.create_all) + - See the sqlalchemy documentation for futher details at + https://docs.sqlalchemy.org/en/14/orm/extensions/asyncio.html#sqlalchemy.ext.asyncio.AsyncEngine + -> async_session + - Relevant for properly manipulating rows and columns within the database, for example: + >>> from sqlalchemy import orm as sqlalchemy_orm + >>> from sqlalchemy import future as sqlalchemy_future + >>> + >>> async with db.async_session() as session: + >>> async with session.begin(): + >>> session.add_all( + >>> [ + >>> A(bs=[B(), B()], data="a1"), + >>> A(bs=[B()], data="a2"), + >>> A(bs=[B(), B()], data="a3"), + >>> ] + >>> ) + >>> + >>> stmt = sqlalchemy_future.select(A).options(sqlalchemy_orm.selectinload(A.bs)) + >>> + >>> result = await session.execute(stmt) + >>> await session.commit() + - See the sqlalchemy documentation for further details at + https://docs.sqlalchemy.org/en/14/orm/extensions/asyncio.html#sqlalchemy.ext.asyncio.AsyncConnection + """ + + Base = Base + + def __init__( + self, + db_url: str, + async_session_expire_on_commit: bool = False, + **engine_kwargs + ): + """ + Constructor for the class + + Args: + db_url: A string defining a standard postgresql url, for instance: postgresql://localhost:5432/postgres + async_session_expire_on_commit: A boolean for determining if the given connection via a async + context manager should close after the session.commit() function is called. + **engine_kwargs: Arguments that can be passed to sqlalchemy's create_async_engine function + """ + self.connection_url = db_url + self.async_engine = sqlalchemy.ext.asyncio.create_async_engine( + self.connection_url, + **engine_kwargs + ) + self.async_session = sqlalchemy.orm.sessionmaker( + self.async_engine, + expire_on_commit=async_session_expire_on_commit, + class_=sqlalchemy.ext.asyncio.AsyncSession + ) + + @property + def connection_url(self) -> str: + """ + Getter for self.connection_url + + Returns: + The postgresql connection string + """ + return self._connection_url + + @connection_url.setter + def connection_url(self, url: str): + """ + Converts a given typical postgresql string to our asynchronous driver used with sqlalchemy + + Args: + url: The given normal postgresql URL + + Returns: + Nothing, setter for self.connection_url in the constructor + """ + self._connection_url = f"postgresql+asyncpg://{url.split('://')[-1]}" + + async def drop_all(self): + """Drops all information from the connected database for the given Base class""" + async with self.async_engine.begin() as conn: + await conn.run_sync(self.Base.metadata.drop_all) + + async def create_all(self): + """Creates all tables for the given Base class""" + async with self.async_engine.begin() as conn: + await conn.run_sync(self.Base.metadata.create_all) diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..8cc5720 --- /dev/null +++ b/setup.py @@ -0,0 +1,12 @@ +from setuptools import setup + +setup( + name='Async-Postgresql-Wrapper', + version='0.8.0', + packages=['Database'], + url='gitlab.orion-technologies.io/Open-Source/Async-Postgresql-Wrapper', + license='MIT', + author='pricehiller', + author_email='philler3138@gmail.com', + description='A simple sqlalchemly database wrapper to simplify asynchronous connections to Postgresql databases with an orm.' +)