├── .gitignore ├── .readthedocs.yaml ├── LICENCE ├── README.md ├── ardilla ├── __init__.py ├── abc.py ├── asyncio │ ├── __init__.py │ ├── crud.py │ └── engine.py ├── crud.py ├── engine.py ├── errors.py ├── fields.py ├── logging.py ├── migration.py ├── models.py ├── ordering.py ├── queries.py └── schemas.py ├── docs ├── api_ref │ ├── crud.md │ ├── engine.md │ └── model.md ├── ardilla_alternatives.md ├── changelog.md ├── css │ └── custom.css ├── guide │ ├── crud.md │ ├── engine.md │ ├── fields.md │ ├── getting_started.md │ ├── migration.md │ └── models.md ├── img │ ├── favicon.ico │ └── logo.png ├── index.md └── licence.md ├── examples ├── basic_usage.py ├── basic_usage_fk.py ├── fastapi_app.py └── rep_discord_bot.py ├── mkdocs.yml ├── pyproject.toml └── tests ├── __init__.py ├── test_async.py ├── test_migration.py ├── test_models.py └── test_sync.py /.gitignore: -------------------------------------------------------------------------------- 1 | foo* 2 | *.db 3 | *config.toml 4 | .pypirc 5 | .vscode/ 6 | # Byte-compiled / optimized / DLL files 7 | __pycache__/ 8 | *.py[cod] 9 | *$py.class 10 | 11 | # C extensions 12 | *.so 13 | 14 | # Distribution / packaging 15 | .Python 16 | build/ 17 | develop-eggs/ 18 | dist/ 19 | downloads/ 20 | eggs/ 21 | .eggs/ 22 | lib/ 23 | lib64/ 24 | parts/ 25 | sdist/ 26 | var/ 27 | wheels/ 28 | share/python-wheels/ 29 | *.egg-info/ 30 | .installed.cfg 31 | *.egg 32 | MANIFEST 33 | 34 | # PyInstaller 35 | # Usually these files are written by a python script from a template 36 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 37 | *.manifest 38 | *.spec 39 | 40 | # Installer logs 41 | pip-log.txt 42 | pip-delete-this-directory.txt 43 | 44 | # Unit test / coverage reports 45 | htmlcov/ 46 | .tox/ 47 | .nox/ 48 | .coverage 49 | .coverage.* 50 | .cache 51 | nosetests.xml 52 | coverage.xml 53 | *.cover 54 | *.py,cover 55 | .hypothesis/ 56 | .pytest_cache/ 57 | cover/ 58 | 59 | # Translations 60 | *.mo 61 | *.pot 62 | 63 | # Django stuff: 64 | *.log 65 | local_settings.py 66 | db.sqlite3 67 | db.sqlite3-journal 68 | 69 | # Flask stuff: 70 | instance/ 71 | .webassets-cache 72 | 73 | # Scrapy stuff: 74 | .scrapy 75 | 76 | # Sphinx documentation 77 | docs/_build/ 78 | 79 | # PyBuilder 80 | .pybuilder/ 81 | target/ 82 | 83 | # Jupyter Notebook 84 | .ipynb_checkpoints 85 | 86 | # IPython 87 | profile_default/ 88 | ipython_config.py 89 | 90 | # pyenv 91 | # For a library or package, you might want to ignore these files since the code is 92 | # intended to run in multiple environments; otherwise, check them in: 93 | # .python-version 94 | 95 | # pipenv 96 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 97 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 98 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 99 | # install all needed dependencies. 100 | #Pipfile.lock 101 | 102 | # poetry 103 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 104 | # This is especially recommended for binary packages to ensure reproducibility, and is more 105 | # commonly ignored for libraries. 106 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 107 | #poetry.lock 108 | 109 | # pdm 110 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 111 | #pdm.lock 112 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 113 | # in version control. 114 | # https://pdm.fming.dev/#use-with-ide 115 | .pdm.toml 116 | 117 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 118 | __pypackages__/ 119 | 120 | # Celery stuff 121 | celerybeat-schedule 122 | celerybeat.pid 123 | 124 | # SageMath parsed files 125 | *.sage.py 126 | 127 | # Environments 128 | .env 129 | .venv 130 | env/ 131 | venv/ 132 | ENV/ 133 | env.bak/ 134 | venv.bak/ 135 | 136 | # Spyder project settings 137 | .spyderproject 138 | .spyproject 139 | 140 | # Rope project settings 141 | .ropeproject 142 | 143 | # mkdocs documentation 144 | /site 145 | 146 | # mypy 147 | .mypy_cache/ 148 | .dmypy.json 149 | dmypy.json 150 | 151 | # Pyre type checker 152 | .pyre/ 153 | 154 | # pytype static type analyzer 155 | .pytype/ 156 | 157 | # Cython debug symbols 158 | cython_debug/ -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | mkdocs: 4 | configuration: mkdocs.yml 5 | fail_on_warning: false 6 | 7 | python: 8 | install: 9 | - method: pip 10 | path: . 11 | extra_requirements: 12 | - docs 13 | 14 | build: 15 | os: ubuntu-22.04 16 | tools: 17 | python: "3.11" -------------------------------------------------------------------------------- /LICENCE: -------------------------------------------------------------------------------- 1 | Copyright © 2023 ChrisDewa chrisdewa@duck.com 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ardilla 2 | 3 | [![Downloads](https://static.pepy.tech/badge/ardilla/month)](https://pepy.tech/project/ardilla) ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/ardilla) ![PyPI](https://img.shields.io/pypi/v/ardilla) ![GitHub](https://img.shields.io/github/license/chrisdewa/ardilla) [![Documentation Status](https://readthedocs.org/projects/ardilla/badge/?version=latest)](https://ardilla.readthedocs.io/en/latest/?badge=latest) 4 | 5 | 6 |
7 | 10 |
11 | 12 | Ardilla (pronounced *ahr-dee-yah*) means "**SQ**uirre**L**" in spanish. 13 | 14 | This library aims to be a simple way to add an SQLite database and 15 | basic C.R.U.D. methods to python applications. 16 | It uses pydantic for data validation and supports a sync engine as well 17 | as an async (aiosqlite) version. 18 | 19 | ## Who and what is this for 20 | 21 | This library is well suited for developers seeking to incorporate SQLite into their python applications to use simple C.R.U.D. methods. 22 | It excels in its simplicity and ease of implementation while it may not be suitable for those who require more complex querying, intricate relationships or top performance. 23 | 24 | For developers who desire more advanced features, there are other libraries available, such as [tortoise-orm](https://github.com/tortoise/tortoise-orm), [sqlalchemy](https://github.com/sqlalchemy/sqlalchemy), [pony](https://github.com/ponyorm/pony) or [peewee](https://github.com/coleifer/peewee). 25 | 26 | 27 | ## Links 28 | 29 | Find Ardilla's source code [here](https://github.com/chrisdewa/ardilla) 30 | 31 | Documentation can be accessed [here](http://ardilla.rtfd.io/) 32 | 33 | ## install 34 | Install lastest release from PyPi 35 | ```bash 36 | pip install -U ardilla 37 | pip install -U ardilla[async] 38 | pip install -U ardilla[dev] 39 | ``` 40 | - async instaslls aiosqlite 41 | - dev installs formatting and testing dependencies 42 | 43 | Or install the lastest changes directly from github 44 | ```bash 45 | pip install git+https://github.com/chrisdewa/ardilla.git 46 | pip install git+https://github.com/chrisdewa/ardilla.git#egg=ardilla[async] 47 | pip install git+https://github.com/chrisdewa/ardilla.git#egg=ardilla[dev] 48 | ``` 49 | 50 | 51 | ## How to use 52 | 53 | ```py 54 | from ardilla import Engine, Model, Crud 55 | from pydantic import Field 56 | 57 | class User(Model): 58 | id: int = Field(primary=True, autoincrement=True) 59 | name: str 60 | age: int 61 | 62 | def main(): 63 | with Engine('db.sqlite') as engine: 64 | user = crud.get_or_none(id=1) # user with id of 1 65 | user2, was_created = crud.get_or_create(id=2, name='chris', age=35) 66 | users = crud.get_many(name='chris') # all users named chris 67 | user3 = User(id=3, name='moni', age=35) 68 | user.age += 1 # it's her birthday 69 | crud.save_one(user3) 70 | crud.save_many(user, user2, user3) 71 | ``` 72 | 73 | ## Supported CRUD methods: 74 | - `crud.insert` Inserts a record, rises errors if there's a conflict 75 | - `crud.insert_or_ignore` Inserts a record or silently ignores if it already exists 76 | - `crud.save_one` upserts an object 77 | - `crud.save_many` upserts many objects 78 | - `crud.get_all` equivalent to `SELECT * FROM tablename` 79 | - `crud.get_many` returns all the objects that meet criteria 80 | - `crud.get_or_create` returns an tuple of the object and a bool, True if the object was newly created 81 | - `crud.get_or_none` Returns the first object meeting criteria if any 82 | - `crud.delete_one` Deletes an object 83 | - `crud.delete_many` Deletes many objects 84 | 85 | 86 | ## Examples: 87 | 88 | - A simple [FastAPI](https://github.com/chrisdewa/ardilla/blob/master/examples/fastapi_app.py) application 89 | - A reputation based discord [bot](https://github.com/chrisdewa/ardilla/blob/master/examples/rep_discord_bot.py) 90 | - [basic usage](https://github.com/chrisdewa/ardilla/blob/master/examples/basic_usage.py) 91 | - [basic usage with foreign keys](https://github.com/chrisdewa/ardilla/blob/master/examples/basic_usage_fk.py) 92 | -------------------------------------------------------------------------------- /ardilla/__init__.py: -------------------------------------------------------------------------------- 1 | from .engine import Engine as Engine 2 | from .models import Model as Model 3 | from .crud import Crud as Crud 4 | from .fields import Field, ForeignField -------------------------------------------------------------------------------- /ardilla/abc.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | import sqlite3 3 | from typing import Any, Literal, TypeVar, Optional, Union 4 | from abc import abstractmethod, ABC 5 | from sqlite3 import Row 6 | 7 | from .models import M, Model as BaseModel 8 | 9 | E = TypeVar("E") # Engine Type 10 | 11 | Connection = TypeVar("Connection") 12 | CrudType = TypeVar('CrudType', bound='BaseCrud') 13 | 14 | 15 | class BaseEngine(ABC): 16 | """This just provides autocompletition across the library""" 17 | 18 | __slots__ = ( 19 | "path", # the path to the database 20 | "schemas", # the registered tables 21 | "tables_created", # a list of tables that were setup 22 | "enable_foreing_keys", # a bool to specify if the pragma should be enforced 23 | "con", # sync connection 24 | "_cruds", # crud cache 25 | ) 26 | 27 | def check_connection(self) -> bool: 28 | """Checks if the engine's connection is alive 29 | works for both the sync and async classes 30 | 31 | Returns: 32 | bool: if the connection is fine 33 | """ 34 | con: Union[Connection, None] = getattr(self, 'con', None) 35 | try: 36 | if isinstance(con, sqlite3.Connection): 37 | con.cursor() 38 | return True 39 | elif con is not None: 40 | # should be aiosqlite 41 | # we don't import it here to prevent import errors 42 | # in case there's missing dependency of aiosqlite 43 | return con._running and con._connection 44 | else: 45 | return None 46 | except: 47 | return False 48 | 49 | def __init__( 50 | self, 51 | path: str, 52 | enable_foreing_keys: bool = False, 53 | ): 54 | self.path = path 55 | self.schemas: set[str] = set() 56 | self.tables_created: set[str] = set() 57 | self._cruds: dict[type[M], CrudType] = {} 58 | self.enable_foreing_keys = enable_foreing_keys 59 | 60 | @abstractmethod 61 | def get_connection(self) -> Connection: 62 | ... 63 | 64 | @abstractmethod 65 | def connect(self) -> Connection: 66 | ... 67 | 68 | @abstractmethod 69 | def close(self) -> None: 70 | ... 71 | 72 | @abstractmethod 73 | def crud(self, Model: type[M]) -> CrudType: 74 | ... 75 | 76 | 77 | class BaseCrud(ABC): 78 | __slots__ = ( 79 | "connection", 80 | "tablename", 81 | "Model", 82 | "columns", 83 | ) 84 | 85 | def __init__(self, Model: type[M], connection: Connection) -> None: 86 | self.Model = Model 87 | self.connection = connection 88 | 89 | self.tablename = Model.__tablename__ 90 | self.columns = tuple(Model.__fields__) 91 | 92 | def __new__(cls, Model: type[M], connection: Connection): 93 | if not issubclass(Model, BaseModel): 94 | raise TypeError("Model param has to be a subclass of model") 95 | 96 | return super().__new__(cls) 97 | 98 | def verify_kws(self, kws: dict[str, Any]) -> Literal[True]: 99 | """Verifies that the passed kws keys in dictionary 100 | are all contained within the model's fields 101 | 102 | Args: 103 | kws (dict[str, Any]): the keyword arguments for queries 104 | 105 | Returns: 106 | Literal[True]: If the kws are verified 107 | """ 108 | for key in kws: 109 | if key not in self.Model.__fields__: 110 | raise KeyError( 111 | f'"{key}" is not a field of the "{self.Model.__name__}" and cannot be used in queries' 112 | ) 113 | return True 114 | 115 | def _row2obj(self, row: Row, rowid: Optional[int] = None) -> BaseModel: 116 | """ 117 | Args: 118 | row: the sqlite row 119 | rowid: the rowid of the row. 120 | If passed it means it comes from an insert function 121 | 122 | """ 123 | keys = list(self.Model.__fields__) 124 | if rowid is None: 125 | rowid, *vals = row 126 | else: 127 | vals = list(row) 128 | data = {k: v for k, v in zip(keys, vals)} 129 | 130 | obj = self.Model(**data) 131 | obj.__rowid__ = rowid 132 | return obj 133 | 134 | # Create 135 | @abstractmethod 136 | def _do_insert(self, ignore: bool = False, returning: bool = True, /, **kws): 137 | ... 138 | 139 | @abstractmethod 140 | def insert(self, **kws): 141 | ... 142 | 143 | @abstractmethod 144 | def insert_or_ignore(self): 145 | ... 146 | 147 | # Read 148 | @abstractmethod 149 | def get_all(self) -> list[M]: 150 | ... 151 | 152 | @abstractmethod 153 | def get_many( 154 | self, 155 | order_by: Optional[dict[str, str]] = None, 156 | limit: Optional[int] = None, 157 | **kws, 158 | ) -> list[M]: 159 | ... 160 | 161 | @abstractmethod 162 | def get_or_create(self, **kws) -> tuple[M, bool]: 163 | ... 164 | 165 | @abstractmethod 166 | def get_or_none(self, **kws) -> Optional[M]: 167 | ... 168 | 169 | # Update 170 | @abstractmethod 171 | def save_one(self, obj: M) -> Literal[True]: 172 | ... 173 | 174 | @abstractmethod 175 | def save_many(self, *objs: M) -> Literal[True]: 176 | ... 177 | 178 | # Delete 179 | @abstractmethod 180 | def delete_one(self, obj: M) -> Literal[True]: 181 | ... 182 | 183 | @abstractmethod 184 | def delete_many(self, *objs: M) -> Literal[True]: 185 | ... 186 | 187 | @abstractmethod 188 | def count(self, column: str = '*', /, **kws) -> int: 189 | ... -------------------------------------------------------------------------------- /ardilla/asyncio/__init__.py: -------------------------------------------------------------------------------- 1 | from .engine import AsyncEngine as Engine 2 | from .crud import AsyncCrud as Crud -------------------------------------------------------------------------------- /ardilla/asyncio/crud.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from typing import Literal, Generic, Optional, Union 3 | 4 | from typing import Any 5 | import aiosqlite 6 | from aiosqlite import Row 7 | 8 | from ..errors import BadQueryError, QueryExecutionError, disconnected_engine_error 9 | from ..models import M 10 | from ..abc import BaseCrud 11 | from ..schemas import SQLFieldType 12 | from ..errors import DisconnectedEngine 13 | from .. import queries 14 | 15 | 16 | class ConnectionProxy: 17 | """A proxy class for aiosqlite.Connection that 18 | checks if the connections is alive before returning any of its attributes 19 | 20 | Args: 21 | connection (aiosqlite.Connection) 22 | """ 23 | def __init__(self, connection: aiosqlite.Connection): 24 | self._connection = connection 25 | 26 | def __getattr__(self, __name: str) -> Any: 27 | if __name in {'execute', 'commit'}: 28 | if not self._connection._running or not self._connection._connection: 29 | raise DisconnectedEngine('The engine is disconnected') 30 | return getattr(self._connection, __name) 31 | 32 | 33 | class AsyncCrud(BaseCrud, Generic[M]): 34 | """Abstracts CRUD actions for model associated tables""" 35 | connection: aiosqlite.Connection 36 | 37 | def __init__(self, Model: type[M], connection: aiosqlite.Connection) -> None: 38 | connection = ConnectionProxy(connection) 39 | super().__init__(Model, connection) 40 | 41 | 42 | async def _do_insert( 43 | self, 44 | ignore: bool = False, 45 | returning: bool = True, 46 | /, 47 | **kws: SQLFieldType, 48 | ): 49 | """private helper method for insertion methods 50 | 51 | Args: 52 | ignore (bool, optional): Ignores conflicts silently. Defaults to False. 53 | returning (bool, optional): Determines if the query should return the inserted row. Defaults to True. 54 | kws (SQLFieldType): the column names and values for the insertion query 55 | 56 | Raises: 57 | QueryExecutionError: when sqlite3.IntegrityError happens because of a conflic 58 | 59 | Returns: 60 | An instance of model if any row is returned 61 | """ 62 | q, vals = queries.for_do_insert(self.tablename, ignore, returning, kws) 63 | 64 | cur = None 65 | try: 66 | cur = await self.connection.execute(q, vals) 67 | except aiosqlite.IntegrityError as e: 68 | raise QueryExecutionError(str(e)) 69 | except aiosqlite.ProgrammingError as e: 70 | raise disconnected_engine_error 71 | else: 72 | row = await cur.fetchone() 73 | await self.connection.commit() 74 | if returning and row: 75 | return self._row2obj(row, cur.lastrowid) 76 | finally: 77 | if cur is not None: 78 | await cur.close() 79 | 80 | async def get_or_none(self, **kws: SQLFieldType) -> Optional[M]: 81 | """Returns a row as an instance of the model if one is found or none 82 | 83 | Args: 84 | kws (SQLFieldType): The keyword arguments are passed as column names and values to 85 | a select query 86 | 87 | Example: 88 | ```py 89 | await crud.get_or_none(id=42) 90 | 91 | # returns an object with id of 42 or None if there isn't one in the database 92 | ``` 93 | Returns: 94 | The object found with the criteria if any 95 | """ 96 | self.verify_kws(kws) 97 | q, vals = queries.for_get_or_none(self.tablename, kws) 98 | 99 | async with self.connection.execute(q, vals) as cur: 100 | row: Union[Row, None] = await cur.fetchone() 101 | if row: 102 | return self._row2obj(row) 103 | return None 104 | 105 | async def insert(self, **kws: SQLFieldType) -> M: 106 | """ 107 | Inserts a record into the database. 108 | 109 | Args: 110 | kws (SQLFieldType): the column names and values for the insertion query 111 | 112 | Returns: 113 | Returns the inserted row as an instance of the model 114 | Rises: 115 | ardilla.error.QueryExecutionError: if there's a conflict when inserting the record 116 | """ 117 | self.verify_kws(kws) 118 | return await self._do_insert(False, True, **kws) 119 | 120 | async def insert_or_ignore(self, **kws: SQLFieldType) -> Optional[M]: 121 | """Inserts a record to the database with the keywords passed. It ignores conflicts. 122 | 123 | Args: 124 | kws (SQLFieldType): The keyword arguments are passed as the column names and values 125 | to the insert query 126 | 127 | Returns: 128 | The newly created row as an instance of the model if there was no conflicts 129 | """ 130 | self.verify_kws(kws) 131 | return await self._do_insert(True, True, **kws) 132 | 133 | async def get_or_create(self, **kws: SQLFieldType) -> tuple[M, bool]: 134 | """Returns an object from the database with the spefied matching data 135 | Args: 136 | kws (SQLFieldType): the key value pairs will be used to query for an existing row 137 | if no record is found then a new row will be inserted 138 | Returns: 139 | A tuple with two values, the object and a boolean indicating if the 140 | object was newly created or not 141 | """ 142 | created = False 143 | result = await self.get_or_none(**kws) 144 | if not result: 145 | result = await self.insert_or_ignore(**kws) 146 | created = True 147 | return result, created 148 | 149 | async def get_all(self) -> list[M]: 150 | """Gets all objects from the database 151 | 152 | Returns: 153 | A list with all the rows in table as instances of the model 154 | """ 155 | q = f"SELECT rowid, * FROM {self.tablename};" 156 | 157 | async with self.connection.execute(q) as cur: 158 | return [self._row2obj(row) for row in await cur.fetchall()] 159 | 160 | async def get_many( 161 | self, 162 | order_by: Optional[dict[str, str]] = None, 163 | limit: Optional[int] = None, 164 | **kws: SQLFieldType, 165 | ) -> list[M]: 166 | """Queries the database and returns objects that meet the criteris 167 | 168 | Args: 169 | order_by (Optional[dict[str, str]], optional): An ordering dict. Defaults to None. 170 | The ordering should have the structure: `{'column_name': 'ASC' OR 'DESC'}` 171 | Case in values is insensitive 172 | kws (SQLFieldType): the column names and values for the select query 173 | 174 | limit (Optional[int], optional): The number of items to return. Defaults to None. 175 | 176 | Returns: 177 | a list of rows matching the criteria as intences of the model 178 | """ 179 | self.verify_kws(kws) 180 | 181 | q, vals = queries.for_get_many( 182 | self.Model, order_by=order_by, limit=limit, kws=kws 183 | ) 184 | async with self.connection.execute(q, vals) as cur: 185 | rows: list[Row] = await cur.fetchall() 186 | return [self._row2obj(row) for row in rows] 187 | 188 | async def save_one(self, obj: M) -> Literal[True]: 189 | """Saves one object to the database 190 | 191 | Args: 192 | obj (M): the object to persist 193 | 194 | Returns: 195 | The literal `True` if the method ran successfuly 196 | """ 197 | q, vals = queries.for_save_one(obj) 198 | 199 | await self.connection.execute(q, vals) 200 | await self.connection.commit() 201 | return True 202 | 203 | async def save_many(self, *objs: M) -> Literal[True]: 204 | """Saves all the passed objects to the database 205 | 206 | Args: 207 | objs (M): the objects to persist 208 | 209 | Returns: 210 | The literal `True` if the method ran successfuly 211 | """ 212 | q, vals = queries.for_save_many(objs) 213 | 214 | await self.connection.executemany(q, vals) 215 | await self.connection.commit() 216 | 217 | return True 218 | 219 | async def delete_one(self, obj: M) -> Literal[True]: 220 | """ 221 | Deletes the object from the database (won't delete the actual object) 222 | If the object has a PK field or the rowid setup, those will be 223 | used to locate the obj and delete it. 224 | If not, this function will delete any row that meets the values of the object 225 | 226 | 227 | Args: 228 | obj (M): the object to delete 229 | 230 | Returns: 231 | The literal `True` if the method ran successfuly 232 | 233 | """ 234 | q, vals = queries.for_delete_one(obj) 235 | 236 | await self.connection.execute(q, vals) 237 | await self.connection.commit() 238 | return True 239 | 240 | async def delete_many(self, *objs: M) -> Literal[True]: 241 | """ 242 | Deletes all the objects passed 243 | 244 | Args: 245 | objs (M): the object to delete 246 | 247 | Returns: 248 | The literal `True` if the method ran successfuly 249 | 250 | """ 251 | q, vals = queries.for_delete_many(objs) 252 | 253 | await self.connection.execute(q, vals) 254 | await self.connection.commit() 255 | 256 | async def count(self, column: str = '*', /, **kws) -> int: 257 | """Returns an integer of the number of non null values in a column 258 | Or the total number of rows if '*' is passed 259 | 260 | Args: 261 | column (str, optional): The column name to count rows on. 262 | Defaults to '*' which counts all the rows in the table 263 | 264 | Returns: 265 | int: the number of rows with non null values in a column or the number of rows in a table 266 | """ 267 | tablename = self.Model.__tablename__ 268 | if column not in self.Model.__fields__ and column != '*': 269 | raise BadQueryError(f'"{column}" is not a field of the "{tablename}" table') 270 | 271 | q, vals = queries.for_count(tablename, column, kws) 272 | async with self.connection.execute(q, vals) as cur: 273 | row = await cur.fetchone() 274 | count = row['total_count'] 275 | 276 | return count -------------------------------------------------------------------------------- /ardilla/asyncio/engine.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from ctypes import Union 3 | 4 | import aiosqlite 5 | 6 | from ..abc import BaseEngine 7 | from ..models import M 8 | from ..errors import DisconnectedEngine 9 | 10 | from .crud import AsyncCrud 11 | 12 | class AsyncEngine(BaseEngine): 13 | """Async Engine that uses `aiosqlite.Connection` and `aiosqlite.Cursor` 14 | """ 15 | con: aiosqlite.Connection 16 | 17 | async def get_connection(self) -> aiosqlite.Connection: 18 | """Gets the connections or makes a new one but it doesn't set it as an attrib 19 | 20 | Returns: 21 | sqlite3.Connection: the connection 22 | """ 23 | con: Union[aiosqlite.Connection, None] = getattr(self, 'con', None) 24 | if not self.check_connection(): 25 | con: aiosqlite.Connection = await aiosqlite.connect(self.path) 26 | con.row_factory = aiosqlite.Row 27 | 28 | if self.enable_foreing_keys: 29 | await con.execute("PRAGMA foreign_keys = on;") 30 | 31 | return con 32 | else: 33 | return self.con 34 | 35 | async def connect(self) -> aiosqlite.Connection: 36 | """ 37 | Stablishes a connection to the database 38 | Returns: 39 | The connection 40 | """ 41 | await self.close() 42 | 43 | self.con = await self.get_connection() 44 | return self.con 45 | 46 | async def close(self) -> None: 47 | if self.check_connection(): 48 | await self.con.close() 49 | self._cruds.clear() 50 | 51 | async def __aenter__(self) -> AsyncEngine: 52 | """Stablishes the connection and if specified enables foreign keys pragma 53 | 54 | Returns: 55 | The connection 56 | """ 57 | await self.connect() 58 | return self 59 | 60 | async def __aexit__(self, *_): 61 | """Closes the connection""" 62 | await self.close() 63 | 64 | async def crud(self, Model: type[M]) -> AsyncCrud[M]: 65 | """ 66 | This function works exactly like `Engine.crud` but 67 | returns an instance of `ardilla.asyncio.crud.AsyncCrud` instead of `ardilla.crud.Crud` 68 | and is asynchronous 69 | 70 | Returns: 71 | The async Crud for the given model 72 | """ 73 | if not self.check_connection(): 74 | raise DisconnectedEngine("Can't create crud objects with a disconnected engine") 75 | 76 | if Model.__schema__ not in self.tables_created: 77 | await self.con.execute(Model.__schema__) 78 | await self.con.commit() 79 | self.tables_created.add(Model.__schema__) 80 | 81 | crud = self._cruds.setdefault(Model, AsyncCrud(Model, self.con)) 82 | return crud 83 | -------------------------------------------------------------------------------- /ardilla/crud.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | import sqlite3 3 | from sqlite3 import Row 4 | from contextlib import contextmanager 5 | from typing import Literal, Generic, Optional, Union, Generator 6 | 7 | from . import queries 8 | from .abc import BaseCrud 9 | from .models import M 10 | from .errors import BadQueryError, QueryExecutionError, disconnected_engine_error, DisconnectedEngine 11 | from .schemas import SQLFieldType 12 | 13 | 14 | @contextmanager 15 | def contextcursor(con: sqlite3.Connection) -> Generator[sqlite3.Cursor, None, None]: 16 | """a context manager wrapper for sqlite3.Cursor 17 | 18 | Args: 19 | con (sqlite3.Connection): the connection 20 | 21 | Raises: 22 | disconnected_engine_error: if the connection is non functioning 23 | 24 | Yields: 25 | Generator[sqlite3.Cursor, None, None]: the cursor 26 | """ 27 | cur = None 28 | try: 29 | cur = con.cursor() 30 | yield cur 31 | except Exception as e: 32 | if ( 33 | isinstance(e, sqlite3.ProgrammingError) 34 | and str(e) == "Cannot operate on a closed database." 35 | ): 36 | raise DisconnectedEngine(str(e)) 37 | else: 38 | raise e 39 | finally: 40 | if cur is not None: 41 | cur.close() 42 | 43 | 44 | class Crud(BaseCrud, Generic[M]): 45 | """Abstracts CRUD actions for model associated tables""" 46 | 47 | connection: sqlite3.Connection 48 | 49 | def _do_insert( 50 | self, 51 | ignore: bool = False, 52 | returning: bool = True, 53 | /, 54 | **kws: SQLFieldType, 55 | ) -> Optional[M]: 56 | """private helper method for insertion methods 57 | 58 | Args: 59 | ignore (bool, optional): Ignores conflicts silently. Defaults to False. 60 | returning (bool, optional): Determines if the query should return the inserted row. Defaults to True. 61 | kws (SQLFieldType): the column name and values for the insert query 62 | 63 | Raises: 64 | QueryExecutionError: when sqlite3.IntegrityError happens because of a conflic 65 | 66 | Returns: 67 | An instance of model if any row is returned 68 | """ 69 | q, vals = queries.for_do_insert(self.tablename, ignore, returning, kws) 70 | 71 | with contextcursor(self.connection) as cur: 72 | try: 73 | cur.execute(q, vals) 74 | except sqlite3.IntegrityError as e: 75 | raise QueryExecutionError(str(e)) 76 | 77 | row = cur.fetchone() 78 | self.connection.commit() 79 | if returning and row: 80 | return self._row2obj(row, cur.lastrowid) 81 | 82 | return None 83 | 84 | def insert(self, **kws: SQLFieldType) -> M: 85 | """Inserts a record into the database. 86 | 87 | Args: 88 | kws (SQLFieldType): The keyword arguments are passed as the column names and values 89 | to the insert query 90 | 91 | Returns: 92 | Creates a new entry in the database and returns the object 93 | 94 | Rises: 95 | `ardilla.error.QueryExecutionError`: if there's a conflict when inserting the record 96 | """ 97 | self.verify_kws(kws) 98 | return self._do_insert(False, True, **kws) 99 | 100 | def insert_or_ignore(self, **kws: SQLFieldType) -> Optional[M]: 101 | """Inserts a record to the database with the keywords passed. It ignores conflicts. 102 | 103 | Args: 104 | kws (SQLFieldType): The keyword arguments are passed as the column names and values 105 | to the insert query 106 | 107 | Returns: 108 | The newly created row as an instance of the model if there was no conflicts 109 | """ 110 | self.verify_kws(kws) 111 | return self._do_insert(True, True, **kws) 112 | 113 | def get_or_none(self, **kws: SQLFieldType) -> Optional[M]: 114 | """Returns a row as an instance of the model if one is found or none 115 | 116 | Args: 117 | kws (SQLFieldType): The keyword arguments are passed as column names and values to 118 | a select query 119 | 120 | Example: 121 | ```py 122 | crud.get_or_none(id=42) 123 | 124 | # returns an object with id of 42 or None if there isn't one in the database 125 | ``` 126 | 127 | Returns: 128 | The object found with the criteria if any 129 | """ 130 | self.verify_kws(kws) 131 | q, vals = queries.for_get_or_none(self.tablename, kws) 132 | with contextcursor(self.connection) as cur: 133 | cur.execute(q, vals) 134 | row: Union[Row, None] = cur.fetchone() 135 | if row: 136 | return self._row2obj(row) 137 | return None 138 | 139 | def get_or_create(self, **kws: SQLFieldType) -> tuple[M, bool]: 140 | """Returns an object from the database with the spefied matching data 141 | Args: 142 | kws (SQLFieldType): the key value pairs will be used to query for an existing row 143 | if no record is found then a new row will be inserted 144 | Returns: 145 | A tuple with two values, the object and a boolean indicating if the 146 | object was newly created or not 147 | """ 148 | self.verify_kws(kws) 149 | created = False 150 | result = self.get_or_none(**kws) 151 | if not result: 152 | result = self.insert_or_ignore(**kws) 153 | created = True 154 | return result, created 155 | 156 | def get_all(self) -> list[M]: 157 | """Gets all objects from the database 158 | Returns: 159 | A list with all the rows in table as instances of the model 160 | """ 161 | return self.get_many() 162 | 163 | def get_many( 164 | self, 165 | order_by: Optional[dict[str, str]] = None, 166 | limit: Optional[int] = None, 167 | **kws: SQLFieldType, 168 | ) -> list[M]: 169 | """Queries the database and returns objects that meet the criteris 170 | 171 | Args: 172 | order_by (Optional[dict[str, str]], optional): An ordering dict. Defaults to None. 173 | The ordering should have the structure: `{'column_name': 'ASC' OR 'DESC'}` 174 | Case in values is insensitive 175 | 176 | limit (Optional[int], optional): The number of items to return. Defaults to None. 177 | kws (SQLFieldType): The column names and values for the select query 178 | 179 | Returns: 180 | a list of rows matching the criteria as intences of the model 181 | """ 182 | self.verify_kws(kws) 183 | q, vals = queries.for_get_many( 184 | self.Model, 185 | order_by=order_by, 186 | limit=limit, 187 | kws=kws, 188 | ) 189 | with contextcursor(self.connection) as cur: 190 | cur.execute(q, vals) 191 | rows: list[Row] = cur.fetchall() 192 | return [self._row2obj(row) for row in rows] 193 | 194 | def save_one(self, obj: M) -> Literal[True]: 195 | """Saves one object to the database 196 | 197 | Args: 198 | obj (M): the object to persist 199 | 200 | Returns: 201 | The literal `True` if the method ran successfuly 202 | """ 203 | q, vals = queries.for_save_one(obj) 204 | try: 205 | self.connection.execute(q, vals) 206 | self.connection.commit() 207 | except: 208 | raise disconnected_engine_error 209 | return True 210 | 211 | def save_many(self, *objs: tuple[M]) -> Literal[True]: 212 | """Saves all the passed objects to the database 213 | 214 | Args: 215 | objs (M): the objects to persist 216 | 217 | Returns: 218 | The literal `True` if the method ran successfuly 219 | """ 220 | q, vals = queries.for_save_many(objs) 221 | try: 222 | self.connection.executemany(q, vals) 223 | self.connection.commit() 224 | except: 225 | raise disconnected_engine_error 226 | 227 | return True 228 | 229 | def delete_one(self, obj: M) -> Literal[True]: 230 | """ 231 | Deletes the object from the database (won't delete the actual object) 232 | If the object has a PK field or the rowid setup, those will be 233 | used to locate the obj and delete it. 234 | If not, this function will delete any row that meets the values of the object 235 | 236 | 237 | Args: 238 | obj (M): the object to delete 239 | 240 | Returns: 241 | The literal `True` if the method ran successfuly 242 | 243 | """ 244 | 245 | q, vals = queries.for_delete_one(obj) 246 | try: 247 | self.connection.execute(q, vals) 248 | self.connection.commit() 249 | except: 250 | raise disconnected_engine_error 251 | return True 252 | 253 | def delete_many(self, *objs: M) -> Literal[True]: 254 | """ 255 | Deletes all the objects passed 256 | 257 | Args: 258 | objs (M): the object to delete 259 | 260 | Returns: 261 | The literal `True` if the method ran successfuly 262 | 263 | """ 264 | q, vals = queries.for_delete_many(objs) 265 | try: 266 | self.connection.execute(q, vals) 267 | self.connection.commit() 268 | except: 269 | raise disconnected_engine_error 270 | return True 271 | 272 | def count(self, column: str = '*',/, **kws) -> int: 273 | """Returns an integer of the number of non null values in a column 274 | Or the total number of rows if '*' is passed 275 | 276 | Args: 277 | column (str, optional): The column name to count rows on. 278 | Defaults to '*' which counts all the rows in the table 279 | 280 | Returns: 281 | int: the number of rows with non null values in a column or the number of rows in a table 282 | """ 283 | self.verify_kws(kws) 284 | 285 | tablename = self.Model.__tablename__ 286 | if column not in self.Model.__fields__ and column != '*': 287 | raise BadQueryError(f'"{column}" is not a field of the "{tablename}" table') 288 | 289 | 290 | q, vals = queries.for_count(tablename, column, kws) 291 | with contextcursor(self.connection) as cur: 292 | cur.execute(q, vals) 293 | row = cur.fetchone() 294 | 295 | count = row['total_count'] 296 | 297 | return count -------------------------------------------------------------------------------- /ardilla/engine.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | import sqlite3 3 | from typing import Union 4 | 5 | from .models import M 6 | from .crud import Crud 7 | from .abc import BaseEngine 8 | from .errors import DisconnectedEngine 9 | 10 | class Engine(BaseEngine): 11 | """The sync engine that uses `sqlite3.Connection` and `sqlite3.Cursor` 12 | 13 | Args: 14 | path (str): a pathlike object that points to the sqlite database 15 | enable_foreing_keys (bool, optional): specifies if the pragma should be enforced. Defaults to False. 16 | 17 | Attributes: 18 | path (str): the path to the db 19 | schemas (set[str]): a set of table schemas 20 | tables_created (set[str]): the tables that have been setup by the engine 21 | enable_foreing_keys (bool): if True, the engine enables the pragma on all connections 22 | """ 23 | con: sqlite3.Connection 24 | 25 | def __init__(self, path: str, enable_foreing_keys: bool = False): 26 | super().__init__(path, enable_foreing_keys) 27 | 28 | def get_connection(self) -> sqlite3.Connection: 29 | """Gets the connections or makes a new one but it doesn't set it as an attrib 30 | 31 | Returns: 32 | sqlite3.Connection: the connection 33 | """ 34 | con: Union[sqlite3.Connection, None] = getattr(self, 'con', None) 35 | if not self.check_connection(): 36 | con = sqlite3.connect(self.path) 37 | con.row_factory = sqlite3.Row 38 | 39 | if self.enable_foreing_keys: 40 | con.execute("PRAGMA foreign_keys = on;") 41 | 42 | return con 43 | else: 44 | return self.con 45 | 46 | def __enter__(self): 47 | self.connect() 48 | return self 49 | 50 | def __exit__(self, *_): 51 | self.close() 52 | 53 | def connect(self) -> sqlite3.Connection: 54 | self.close() 55 | self.con = self.get_connection() 56 | return self.con 57 | 58 | def close(self) -> None: 59 | if self.check_connection(): 60 | self.con.close() 61 | self._cruds.clear() 62 | 63 | def crud(self, Model: type[M]) -> Crud[M]: 64 | """returns a Crud instances for the given model type 65 | 66 | Args: 67 | Model (type[M]): the model type for the crud object 68 | 69 | Returns: 70 | Crud[M]: the crud for the model type 71 | """ 72 | if not self.check_connection(): 73 | raise DisconnectedEngine("Can't create crud objects with a disconnected engine") 74 | 75 | if Model.__schema__ not in self.tables_created: 76 | self.con.execute(Model.__schema__) 77 | self.con.commit() 78 | self.tables_created.add(Model.__schema__) 79 | 80 | crud = self._cruds.setdefault(Model, Crud(Model, self.con)) 81 | 82 | return crud 83 | 84 | -------------------------------------------------------------------------------- /ardilla/errors.py: -------------------------------------------------------------------------------- 1 | """ 2 | Contains the module's errors 3 | """ 4 | 5 | 6 | class ArdillaException(Exception): 7 | pass 8 | 9 | 10 | class ModelIntegrityError(ArdillaException): 11 | pass 12 | 13 | 14 | class MissingEngine(ArdillaException): 15 | pass 16 | 17 | 18 | class QueryExecutionError(ArdillaException): 19 | pass 20 | 21 | 22 | class BadQueryError(ArdillaException): 23 | pass 24 | 25 | 26 | class DisconnectedEngine(ArdillaException): 27 | pass 28 | 29 | 30 | disconnected_engine_error = DisconnectedEngine( 31 | "The engine has been disconnected and cannot operate on the database" 32 | ) 33 | 34 | 35 | class MigrationError(ArdillaException): 36 | pass -------------------------------------------------------------------------------- /ardilla/fields.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | from pydantic import Field 3 | from ardilla import Model 4 | 5 | class _ForeignFieldMaker(): 6 | """ 7 | Helper class to generate foreing key field constrains. 8 | 9 | Intead of instantiating this class the developer should use 10 | the already instantiated `ardilla.fields.ForeignKey` 11 | instead of directly instantiating this class. 12 | 13 | Attributes: 14 | NO_ACTION (str): (class attribute) The database won't take action. This most likely will result in errors 15 | RESTRICT (str): (class attribute) The app will not be able to delete the foreing row unless there's no related child elements left 16 | SET_NULL (str): (class attribute) The app will set the child to Null if the parent is deleted 17 | SET_DEFAULT (str): (class attribute) Returns the value of this field to the default of the child when the parent is deleted or updated 18 | CASCADE (str): (class attribute) If the parent gets deleted or updated the child follows 19 | 20 | """ 21 | NO_ACTION = 'NO ACTION' 22 | RESTRICT = 'RESTRICT' 23 | SET_NULL = 'SET NULL' 24 | SET_DEFAULT = 'SET DEFAULT' 25 | CASCADE = 'CASCADE' 26 | 27 | def __call__( 28 | self, 29 | *, 30 | references: type[Model], 31 | on_delete: str = NO_ACTION, 32 | on_update: str = NO_ACTION, 33 | **kws, 34 | ) -> Any: 35 | """ 36 | Args: 37 | references (type[Model]): 38 | The model this foreign key points to 39 | on_delete (str): defaults to 'NO ACTION' 40 | what happens when the referenced row gets deleted 41 | on_update (str): defaults to 'NO ACTION' 42 | what happens when the referenced row gets updated 43 | Returns: 44 | A `pydantic.Field` with extra metadata for the schema creation 45 | Raises: 46 | KeyError: if the referenced value is not a type of model 47 | ValueError: if the referenced model does not have a primary key or has not yet been instantiated 48 | """ 49 | if not issubclass(references, Model): 50 | raise TypeError('The referenced type must be a subclass of ardilla.Model') 51 | fk = getattr(references, '__pk__', None) 52 | tablename = getattr(references, '__tablename__') 53 | 54 | if not fk: 55 | raise ValueError('The referenced model requires to have a primary key') 56 | 57 | return Field( 58 | references=tablename, 59 | fk=fk, 60 | on_delete=on_delete, 61 | on_update=on_update, 62 | **kws 63 | ) 64 | 65 | ForeignField = _ForeignFieldMaker() -------------------------------------------------------------------------------- /ardilla/logging.py: -------------------------------------------------------------------------------- 1 | from logging import getLogger 2 | from typing import Optional 3 | 4 | log = getLogger('ardilla') 5 | 6 | def log_query(q: str, vals: Optional[tuple] = None): 7 | vals = vals or () 8 | log.debug(f'Querying: {q} - values: {vals}') 9 | 10 | -------------------------------------------------------------------------------- /ardilla/migration.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | 4 | from .models import Model 5 | from .errors import MigrationError 6 | from .schemas import make_field_schema, make_table_schema 7 | 8 | 9 | 10 | def generate_migration_script( 11 | old: type[Model], 12 | new: type[Model], 13 | *, 14 | original_tablename: str, 15 | new_tablename: Optional[str] = None 16 | ) -> str: 17 | """_summary_ 18 | 19 | Args: 20 | old (type[Model]): the old model 21 | new (type[Model]): the new model 22 | original_tablename (str): the tablename as it is in the database before migrating 23 | new_tablename (Optional[str], optional): If the table should change its name this is the new one. Defaults to None. 24 | 25 | Raises: 26 | MigrationError: Migration includes a new field with unique constraint 27 | MigrationError: Migration includes a new field with primary key constraint 28 | MigrationError: Migration includes a not null field without a default value 29 | 30 | Returns: 31 | str: The migration script. Execute it with an sqlite3 connection 32 | """ 33 | scripts = [] 34 | 35 | if new_tablename is not None: 36 | scripts.append( 37 | f"ALTER TABLE {original_tablename} RENAME TO {new_tablename};" 38 | ) 39 | 40 | tablename = tablename if not new_tablename else new_tablename 41 | 42 | old_fields = set(old.__fields__) 43 | new_fields = set(new.__fields__) 44 | 45 | dropped = old_fields - new_fields 46 | for field_name in dropped: 47 | scripts.append(f"ALTER TABLE {tablename} DROP COLUMN {field_name};") 48 | 49 | added = new_fields - old_fields 50 | for field_name in added: 51 | field = new.__fields__[field_name] 52 | schema = make_field_schema(field) 53 | if schema["unique"]: 54 | raise MigrationError( 55 | f"cannot process '{field_name}' because it's marked as unique" 56 | ) 57 | continue 58 | if schema["pk"]: 59 | raise MigrationError( 60 | f"cannot process '{field_name}' because it's marked as primary key" 61 | ) 62 | field_schema = schema["schema"] 63 | if "NOT NULL" in field_schema and not "DEFAULT" in field_schema: 64 | raise MigrationError( 65 | f'Cannot script a "not null" field without default value in field "{field_name}"' 66 | ) 67 | 68 | scripts.append(f"ALTER TABLE {tablename} ADD COLUMN {field_schema};") 69 | 70 | conserved = old_fields & new_fields 71 | alter_fields = False 72 | for f in conserved: 73 | old_schema = make_field_schema(old.__fields__[f]) 74 | new_schema = make_field_schema(new.__fields__[f]) 75 | if old_schema != new_schema: 76 | alter_fields = True 77 | 78 | # if old.__fields__[f].type_ != new.__fields__[f].type_: 79 | # print( 80 | # f"Ardilla can't handle type changes for now. " 81 | # f"You'll have to migrate this on your own." 82 | # ) 83 | # alter_fields = False 84 | # break 85 | 86 | if alter_fields is True: 87 | new_table_schema = make_table_schema(new) 88 | cols = ', '.join(name for name in new.__fields__) 89 | 90 | script = f''' 91 | \rALTER TABLE {tablename} RENAME TO _{tablename}; 92 | \r 93 | \r{new_table_schema} 94 | \r 95 | \rINSERT INTO {tablename} ({cols}) 96 | \r SELECT {cols} 97 | \r FROM _{tablename}; 98 | \r 99 | \rDROP TABLE _{tablename}; 100 | \r''' 101 | 102 | scripts.append(script) 103 | 104 | 105 | return "\n\n".join(scripts) 106 | 107 | -------------------------------------------------------------------------------- /ardilla/models.py: -------------------------------------------------------------------------------- 1 | """ 2 | Contains the Model object and typing alias to work with the engines and Cruds 3 | """ 4 | 5 | from typing import Optional, TypeVar 6 | from pydantic import BaseModel, PrivateAttr 7 | 8 | from .schemas import make_table_schema, FIELD_MAPPING, get_tablename, get_pk 9 | from .errors import ModelIntegrityError 10 | 11 | 12 | class Model(BaseModel): 13 | """ 14 | The base model representing SQLite tables 15 | Inherits directly from pydantic.BaseModel 16 | 17 | Attributes: 18 | __rowid__ (int | None): (class attribute) when an object is returned by a query it will 19 | contain the rowid field that can be used for update and deletion. 20 | __pk__ (str | None): (class attribute) Holds the primary key column name of the table 21 | __tablename__ (str): (class attribute) the name of the table in the database 22 | __schema__(str): the (class attribute) schema for the table. 23 | 24 | Example: 25 | ```py 26 | from ardilla import Model, Field 27 | # Field is actually pydantic.Field but it's imported here for the convenience of the developer 28 | 29 | class User(Model): 30 | __tablename__ = 'users' # by default the tablename is just the model's name in lowercase 31 | id: int = Field(primary=True) # sets this field as the primary key 32 | name: str 33 | ``` 34 | """ 35 | __rowid__: Optional[int] = PrivateAttr(default=None) 36 | __pk__: Optional[str] # tells the model which key to idenfity as primary 37 | __tablename__: str # will default to the lowercase name of the subclass 38 | __schema__: str # best effort will be made if it's missing 39 | # there's no support for constrains or foreign fields yet but you can 40 | # define your own schema to support them 41 | 42 | def __init_subclass__(cls, **kws) -> None: 43 | 44 | for field in cls.__fields__.values(): 45 | if field.type_ not in FIELD_MAPPING: 46 | raise ModelIntegrityError( 47 | f'Field "{field.name}" of model "{cls.__name__}" is of unsupported type "{field.type_}"' 48 | ) 49 | 50 | if field.field_info.extra.keys() & {'primary', 'primary_key', 'pk'}: 51 | if getattr(cls, '__pk__', None) not in {None, field.name}: 52 | raise ModelIntegrityError('More than one fields defined as primary') 53 | 54 | cls.__pk__ = field.name 55 | 56 | if not hasattr(cls, "__schema__"): 57 | cls.__schema__ = make_table_schema(cls) 58 | 59 | if not hasattr(cls, '__pk__'): 60 | cls.__pk__ = get_pk(cls.__schema__) 61 | 62 | if not hasattr(cls, "__tablename__"): 63 | tablename = get_tablename(cls) 64 | setattr(cls, "__tablename__", tablename) 65 | 66 | super().__init_subclass__(**kws) 67 | 68 | def __str__(self) -> str: 69 | return f"{self!r}" 70 | 71 | 72 | M = TypeVar("M", bound=Model) 73 | -------------------------------------------------------------------------------- /ardilla/ordering.py: -------------------------------------------------------------------------------- 1 | from typing import Container 2 | 3 | def validate_ordering(columns: Container[str], order_by: dict[str, str]) -> dict[str, str]: 4 | """validates an ordering dictionary 5 | The ordering should have this structure: 6 | { 7 | 'column_name': 'ASC' OR 'DESC' 8 | } 9 | Case in values is insensitive 10 | 11 | Args: 12 | columns (Container[str]): a collection of columns to check the keys against 13 | order_by (dict[str, str]): 14 | 15 | Raises: 16 | KeyError: if the key is not listed in the columns of the table 17 | ValueError: if the value is not either ASC or DESC 18 | 19 | Returns: 20 | dict[str, str]: a copy of the ordering dict with values in uppercase 21 | """ 22 | out = order_by.copy() 23 | for k, v in order_by.items(): 24 | if k not in columns: 25 | raise KeyError(f'"{k}" is not a valid column name') 26 | elif v.lower() not in {'desc', 'asc'}: 27 | raise ValueError(f'"{k}" value "{v}" is invalid, must be either "asc" or "desc" (case insensitive)') 28 | else: 29 | out[k] = v.upper() 30 | return out -------------------------------------------------------------------------------- /ardilla/queries.py: -------------------------------------------------------------------------------- 1 | """ 2 | Methods here are used by Crud classes to obtain the query 3 | strings and variable tuples to pass to the connections and cursors 4 | """ 5 | from typing import Any, Optional, Union 6 | from .errors import BadQueryError 7 | from .models import M 8 | from .ordering import validate_ordering 9 | from .logging import log_query 10 | 11 | 12 | def for_get_or_none(tablename: str, kws: dict) -> tuple[str, tuple[Any, ...]]: 13 | """called by _get_or_none_one method 14 | Args: 15 | tablename (str): name of the table 16 | kws (dict): the keywords to identify the rows with 17 | Returns: 18 | tuple[str, tuple[Any, ...]]: the query and values. 19 | """ 20 | keys, vals = zip(*kws.items()) 21 | to_match = f" AND ".join(f"{k} = ?" for k in keys) 22 | q = f"SELECT rowid, * FROM {tablename} WHERE ({to_match}) LIMIT 1;" 23 | log_query(q, vals) 24 | return q, vals 25 | 26 | 27 | def for_get_many( 28 | Model: M, 29 | *, 30 | order_by: Optional[dict[str, str]] = None, 31 | limit: Optional[int] = None, 32 | kws: dict, 33 | ) -> tuple[str, tuple[Any, ...]]: 34 | """called by _get_many method 35 | Args: 36 | Args: 37 | Model (Model): the model of the table 38 | order_by (dict[str, str] | None ): 39 | if passed Defines the sorting methods for the query 40 | defaults to no sorting 41 | limit (int | None) an integer to determine the number of items to grab 42 | kws (dict): the keywords to identify the rows with 43 | """ 44 | tablename = Model.__tablename__ 45 | columns = tuple(Model.__fields__) 46 | 47 | if kws: 48 | keys, vals = zip(*kws.items()) 49 | to_match = f" AND ".join(f"{k} = ?" for k in keys) 50 | filter_ = f" WHERE ({to_match})" 51 | else: 52 | filter_ = "" 53 | vals = () 54 | 55 | if order_by is not None: 56 | ord = validate_ordering(columns, order_by) 57 | order_by_q = f" ORDER BY " + ", ".join(f"{k} {v}" for k, v in ord.items()) 58 | else: 59 | order_by_q = "" 60 | 61 | if limit is not None: 62 | if not isinstance(limit, int) or limit < 1: 63 | raise ValueError("Limit, when passed, must be an integer larger than zero") 64 | limit_q = " LIMIT ?" 65 | vals += (limit,) 66 | else: 67 | limit_q = "" 68 | 69 | q = f"SELECT rowid, * FROM {tablename}{filter_}{order_by_q}{limit_q};" 70 | return q, vals 71 | 72 | 73 | def for_do_insert( 74 | tablename: str, 75 | ignore: bool, 76 | returning: bool, 77 | kws: dict, 78 | ) -> tuple[str, tuple[Any, ...]]: 79 | """called by _do_insert methods 80 | 81 | Args: 82 | tablename (str): name of the table 83 | ignore (bool): whether or not to use `INSERT OR IGNORE` vs just `INSERT` 84 | returning (bool): if the inserted values should be returned by the query 85 | kws (dict): the keywords representing column name and values 86 | 87 | Returns: 88 | tuple[str, tuple[Any, ...]]: the queries and values 89 | """ 90 | keys, vals = zip(*kws.items()) 91 | placeholders = ", ".join("?" * len(keys)) 92 | cols = ", ".join(keys) 93 | 94 | q = "INSERT OR IGNORE " if ignore else "INSERT " 95 | q += f"INTO {tablename} ({cols}) VALUES ({placeholders})" 96 | q += " RETURNING *;" if returning else ";" 97 | log_query(q, vals) 98 | return q, vals 99 | 100 | 101 | def for_save_one(obj: M) -> tuple[str, tuple[Any, ...]]: 102 | """called by save_one methods 103 | 104 | Args: 105 | obj (M): the Model instance to save 106 | 107 | Returns: 108 | tuple[str, tuple[Any, ...]]: the query and values 109 | """ 110 | cols, vals = zip(*obj.dict().items()) 111 | 112 | if obj.__rowid__ is not None: 113 | q = f""" 114 | UPDATE {obj.__tablename__} SET {', '.join(f'{k} = ?' for k in cols)} WHERE rowid = ?; 115 | """ 116 | vals += (obj.__rowid__,) 117 | 118 | else: 119 | placeholders = ", ".join("?" * len(cols)) 120 | q = f""" 121 | INSERT OR REPLACE INTO {obj.__tablename__} ({', '.join(cols)}) VALUES ({placeholders}); 122 | """ 123 | log_query(q, vals) 124 | return q, vals 125 | 126 | 127 | def for_save_many(objs: tuple[M]) -> tuple[str, tuple[Any, ...]]: 128 | """called by save_many methods 129 | 130 | Args: 131 | objs (tuple[M]): the objects to save 132 | 133 | Raises: 134 | BadQueryError: if the objs tuple is empty 135 | 136 | Returns: 137 | tuple[str, tuple[Any, ...]]: the query and values 138 | """ 139 | if not objs: 140 | raise BadQueryError("To save many, you have to at least past one object") 141 | cols = tuple(objs[0].__fields__) 142 | tablename = objs[0].__tablename__ 143 | placeholders = ", ".join("?" * len(cols)) 144 | q = f'INSERT OR REPLACE INTO {tablename} ({", ".join(cols)}) VALUES ({placeholders});' 145 | vals = tuple(tuple(obj.dict().values()) for obj in objs) 146 | log_query(q, vals) 147 | return q, vals 148 | 149 | 150 | def for_delete_one(obj: M) -> tuple[str, tuple[Any, ...]]: 151 | """called by delete_one methods 152 | 153 | Args: 154 | obj (M): the object to delete 155 | 156 | Returns: 157 | tuple[str, tuple[Any, ...]]: the query and values 158 | """ 159 | tablename = obj.__tablename__ 160 | if obj.__pk__: 161 | q = f"DELETE FROM {tablename} WHERE {obj.__pk__} = ?" 162 | vals = (getattr(obj, obj.__pk__),) 163 | elif obj.__rowid__: 164 | q = f"DELETE FROM {tablename} WHERE rowid = ?" 165 | vals = (obj.__rowid__,) 166 | else: 167 | obj_dict = obj.dict() 168 | placeholders = " AND ".join(f"{k} = ?" for k in obj_dict) 169 | vals = tuple(obj_dict[k] for k in obj_dict) 170 | q = f""" 171 | DELETE FROM {tablename} WHERE ({placeholders}); 172 | """ 173 | log_query(q, vals) 174 | return q, vals 175 | 176 | 177 | def for_delete_many(objs: tuple[M]) -> tuple[str, tuple[Any, ...]]: 178 | """called by delete_many methods 179 | 180 | Args: 181 | objs (tuple[M]): objects to delete 182 | 183 | Raises: 184 | IndexError: if the the obj tuple is empty 185 | BadQueryError: if the objects don't have either rowid or pks 186 | 187 | Returns: 188 | tuple[str, tuple[Any, ...]] 189 | """ 190 | if not objs: 191 | raise IndexError('param "objs" is empty, pass at least one object') 192 | 193 | tablename = objs[0].__tablename__ 194 | placeholders = ", ".join("?" * len(objs)) 195 | if all(obj.__rowid__ for obj in objs): 196 | vals = tuple(obj.__rowid__ for obj in objs) 197 | q = f"DELETE FROM {tablename} WHERE rowid IN ({placeholders})" 198 | 199 | elif (pk := objs[0].__pk__) and all(getattr(o, pk, None) is not None for o in objs): 200 | vals = tuple(getattr(obj, pk) for obj in objs) 201 | q = f"DELETE FROM {tablename} WHERE id IN ({placeholders})" 202 | 203 | else: 204 | raise BadQueryError( 205 | "Objects requiere either a primary key or the rowid set for mass deletion" 206 | ) 207 | 208 | log_query(q, vals) 209 | return q, vals 210 | 211 | 212 | def for_count(tablename: str, column: str = '*', kws: Optional[dict] = None) -> tuple[str, tuple]: 213 | """Returns a query for counting the number of non null values in a column 214 | 215 | Args: 216 | tablename (str): The name of the table. 217 | column (str, optional): The column to count. . Defaults to '*' which then counts all the rows 218 | kws (dict, optional): The key/value pair for the "WHERE" clausule 219 | If not specified the complete table will be used. 220 | 221 | Returns: 222 | tuple: the query and vals 223 | """ 224 | q = f'SELECT COUNT({column}) AS total_count FROM {tablename}' 225 | 226 | vals = () 227 | if kws: 228 | keys, vals = zip(*kws.items()) 229 | placeholders = ', '.join(f'{k} = ?' for k in keys) 230 | q += f' WHERE {placeholders};' 231 | 232 | return q, vals 233 | 234 | -------------------------------------------------------------------------------- /ardilla/schemas.py: -------------------------------------------------------------------------------- 1 | """ 2 | variables and functions here are used to generate and work with the Model's schemas 3 | """ 4 | import re 5 | from typing import Optional, Union 6 | from datetime import datetime, date, time 7 | from pydantic import BaseModel, Json 8 | from pydantic.fields import ModelField 9 | 10 | from .errors import ModelIntegrityError 11 | 12 | 13 | SCHEMA_TEMPLATE: str = "CREATE TABLE IF NOT EXISTS {tablename} (\n{fields}\n);" 14 | 15 | SQLFieldType = Union[int, float, str, bool, datetime, bytes, date, time] 16 | 17 | FIELD_MAPPING: dict[type, str] = { 18 | int: "INTEGER", 19 | float: "REAL", 20 | str: "TEXT", 21 | bool: "INTEGER", 22 | datetime: "DATETIME", 23 | bytes: "BLOB", 24 | date: "DATE", 25 | time: "TIME", 26 | } 27 | 28 | AUTOFIELDS = { 29 | int: " AUTOINCREMENT", 30 | datetime: " DEFAULT CURRENT_TIMESTAMP", 31 | date: " DEFAULT CURRENT_DATE", 32 | time: " DEFAULT CURRENT_TIME", 33 | } 34 | 35 | 36 | def get_tablename(model: type[BaseModel]) -> str: 37 | """returns the tablename of a model either from the attribute __tablenam__ 38 | or from the lowercase model's name 39 | 40 | Args: 41 | model (type[BaseModel]): the model 42 | 43 | Returns: 44 | str: the name of the table 45 | """ 46 | return getattr(model, "__tablename__", model.__name__.lower()) 47 | 48 | 49 | def make_field_schema(field: ModelField) -> dict: 50 | output = {} 51 | name = field.name 52 | T = field.type_ 53 | default = field.default 54 | extra = field.field_info.extra 55 | auto = output["auto"] = extra.get("auto") 56 | unique = output["unique"] = extra.get("unique") 57 | is_pk = False 58 | constraint = None 59 | 60 | if default and unique: 61 | raise ModelIntegrityError( 62 | "field {name} has both unique and default constrains which are incompatible" 63 | ) 64 | 65 | autoerror = ModelIntegrityError( 66 | f'field {name} has a type of "{T}" which does not support "auto"' 67 | ) 68 | schema = f"{name} {FIELD_MAPPING[T]}" 69 | 70 | primary_field_keys = {"pk", "primary", "primary_key"} 71 | if len(extra.keys() & primary_field_keys) > 1: 72 | raise ModelIntegrityError(f'Multiple keywords for a primary field in "{name}"') 73 | 74 | for k in primary_field_keys: 75 | if k in extra and extra[k]: 76 | is_pk = True 77 | 78 | schema += " PRIMARY KEY" 79 | 80 | if auto and T in AUTOFIELDS: 81 | schema += AUTOFIELDS[T] 82 | field.required = ( 83 | False # to allow users to create the objs without this field 84 | ) 85 | 86 | elif auto: 87 | raise autoerror 88 | 89 | break 90 | else: 91 | if auto and T in AUTOFIELDS.keys() - {int}: 92 | schema += AUTOFIELDS[T] 93 | elif auto: 94 | raise autoerror 95 | elif default is not None: 96 | if T in {int, str, float, bool}: 97 | schema += f" DEFAULT {default!r}" 98 | elif T in {datetime, date, time}: 99 | schema += f" DEFAULT {default}" 100 | elif T is bytes: 101 | schema += f" DEFAULT (X'{default.hex()}')" 102 | elif field.required: 103 | schema += " NOT NULL" 104 | if unique: 105 | schema += " UNIQUE" 106 | 107 | if extra.get("references"): 108 | references, fk, on_delete, on_update = ( 109 | extra.get(f) for f in ["references", "fk", "on_delete", "on_update"] 110 | ) 111 | constraint = ( 112 | f"FOREIGN KEY ({name}) " 113 | f"REFERENCES {references}({fk}) " 114 | f"ON UPDATE {on_update} " 115 | f"ON DELETE {on_delete}" 116 | ) 117 | 118 | output.update({"pk": is_pk, "schema": schema, "constraint": constraint}) 119 | 120 | return output 121 | 122 | 123 | def make_table_schema(Model: type[BaseModel]) -> str: 124 | tablename = get_tablename(Model) 125 | fields = [] 126 | constrains = [] 127 | pk = None 128 | for field in Model.__fields__.values(): 129 | name = field 130 | field_schema = make_field_schema(field) 131 | if field_schema["pk"] is True: 132 | if pk is not None: 133 | raise ModelIntegrityError( 134 | f'field "{name}" is marked as primary but there is already a primary key field "{pk}"' 135 | ) 136 | pk = field.name 137 | fields.append(field_schema["schema"]) 138 | 139 | constrains.append(field_schema["constraint"]) if field_schema[ 140 | "constraint" 141 | ] else None 142 | 143 | schema = ( 144 | f"CREATE TABLE IF NOT EXISTS {tablename}(\n" 145 | + ",\n".join(f"\r {f}" for f in (fields + constrains)) 146 | + "\n);" 147 | ) 148 | return schema 149 | 150 | 151 | def get_pk(schema: str) -> Optional[str]: 152 | """Gets the primary key field name from the passed schema 153 | 154 | Args: 155 | schema (str): table schema 156 | 157 | Returns: 158 | Optional[str]: the name of the primary key if any 159 | """ 160 | # Check if the schema contains a primary key definition 161 | if "PRIMARY KEY" in schema: 162 | # Use a regular expression to extract the primary key column name 163 | match = re.search(r"(?i)\b(\w+)\b\s+(?:\w+\s+)*PRIMARY\s+KEY", schema) 164 | if match: 165 | return match.group(1) 166 | return None 167 | -------------------------------------------------------------------------------- /docs/api_ref/crud.md: -------------------------------------------------------------------------------- 1 | # CRUD Classes 2 | 3 | # Sync CRUD 4 | ::: ardilla.crud.Crud 5 | 6 | # Async CRUD 7 | ::: ardilla.asyncio.crud.AsyncCrud -------------------------------------------------------------------------------- /docs/api_ref/engine.md: -------------------------------------------------------------------------------- 1 | # Engines 2 | 3 | # Sync Engine 4 | 5 | ::: ardilla.engine.Engine 6 | 7 | # Async Engine 8 | 9 | ::: ardilla.asyncio.engine.AsyncEngine -------------------------------------------------------------------------------- /docs/api_ref/model.md: -------------------------------------------------------------------------------- 1 | # Model 2 | ::: ardilla.models.Model 3 | 4 | # Fields 5 | 6 | ::: ardilla.fields._ForeignFieldMaker -------------------------------------------------------------------------------- /docs/ardilla_alternatives.md: -------------------------------------------------------------------------------- 1 | # Alternatives for Ardilla 2 | 3 | - [tortoise-orm](https://github.com/tortoise/tortoise-orm) 4 | - [sqlalchemy](https://github.com/sqlalchemy/sqlalchemy) 5 | - [pony](https://github.com/ponyorm/pony) 6 | - [peewee](https://github.com/coleifer/peewee) 7 | 8 | -------------------------------------------------------------------------------- /docs/changelog.md: -------------------------------------------------------------------------------- 1 | # changelog 2 | 3 | The changelog started on version 0.4.0-beta. 4 | 5 | ## changes: 6 | 7 |
8 | **0.4.0-beta:** Added a migration script generator. Improved schema generation. 9 |
-------------------------------------------------------------------------------- /docs/css/custom.css: -------------------------------------------------------------------------------- 1 | /* Add your logo */ 2 | .md-header__title:before { 3 | content: url("../img/ardilla_logo.png"); 4 | margin-right: 8px; /* Adjust the margin as needed */ 5 | } -------------------------------------------------------------------------------- /docs/guide/crud.md: -------------------------------------------------------------------------------- 1 | # CRUD 2 | 3 | ## Basics 4 | 5 | The CRUD objects are the functional bit of ardilla. They allow you to interact with the database to Create, Read, Update and Delete records. 6 | 7 | ## Methods 8 | 9 | - `crud.insert` Inserts a record, rises errors if there's a conflict 10 | - `crud.insert_or_ignore` Inserts a record or silently ignores if it already exists 11 | - `crud.save_one` upserts an object 12 | - `crud.save_many` upserts many objects 13 | - `crud.get_all` equivalent to `SELECT * FROM tablename` 14 | - `crud.get_many` returns all the objects that meet criteria 15 | - `crud.get_or_create` returns an tuple of the object and a bool, True if the object was newly created 16 | - `crud.get_or_none` Returns the first object meeting criteria if any 17 | - `crud.delete_one` Deletes an object 18 | - `crud.delete_many` Deletes many objects 19 | 20 | 21 | In the next sections we go into detail on how to use the methods. 22 | We'll work with these models 23 | ```py 24 | class User(Model): 25 | id: int = Field(pk=True, auto=True) 26 | name: str 27 | age: int 28 | ``` 29 | 30 | 31 | ### get_all 32 | 33 | ```py 34 | users = crud.get_all() 35 | ``` 36 | Retrieves all the objects from the database. Works as a filterless `get_many` 37 | 38 | ### get many filters and orders objects at database level 39 | 40 | ```py 41 | # get all users named chris 42 | users = crud.get_many(name='chris') 43 | ``` 44 | 45 | ```py 46 | # get 20 users at most named chris and order them by age in descending order 47 | users = crud.get_many( 48 | order_by={'age': 'DESC'}, 49 | limit=20, 50 | name='chris' 51 | ) 52 | ``` 53 | The `order_by` parameter takes a dictionary where the keys must be the field names, and the values are either `"ASC"` or `"DESC"`. 54 | The `limit` parameter is an integer, following SQLite specification, a number less than 1 returns an empty list, a higher number returns 55 | a list of at most that length. 56 | 57 | ### get_or_create 58 | 59 | ```py 60 | result = crud.get_or_create(name='chris', age=35) 61 | # result is a tuple of the object and a bool 62 | # True if the object was newly created 63 | obj, created = result 64 | ``` 65 | `get_or_create` returns a tuple that tells you if the object was newly created, if you don't care about if it was or not newly created, you can unpack the result like this `user,_ = crud.get_or_create(name='chris', age=35)`. 66 | 67 | ### get_or_none 68 | ```py 69 | user = crud.get_or_none(name='hdjkdhjaskhdsajkashasjk', age=999999) 70 | # user is None 71 | user = crud.get_or_none(name='chris', age=35) 72 | # user is chris 73 | ``` 74 | Returns only a result if it already exists in the database, else, it returns the None value 75 | 76 | ### insert 77 | 78 | ```py 79 | obj = crud.insert(name='chris', age=35) 80 | ``` 81 | We skip the id since we specified "auto" in the model schema and this translates to "autoincrement". 82 | The object that was returned will have `__rowid__` and `id` fields filled up with data from the db. 83 | 84 | ### insert_or_ignore 85 | ```py 86 | obj = crud.insert_or_ignore(id=2, name='moni', age=34) 87 | # the obj here exists since it was newly created 88 | obj2 = crud.insert_or_ignore(id=2, name='moni', age=34) 89 | # the object is now None since it already existed 90 | # the crud won't bring back the existing record 91 | ``` 92 | We specify the id at creation and we get the object back, but if we try to insert it again, the obj variablle will be `None` 93 | 94 | ### save_one 95 | 96 | ```py 97 | u = get_or_none(name='moni') 98 | # u is User(id=2, name='moni', age=34) 99 | u.age += 1 # it's her birthday 100 | crud.save_one(u) 101 | ``` 102 | To save an object to the database we can create a new instance of `User` or we can use one object from the database. It's best to use the save and delete methods with objects and fields that have either a `__pk__` field (primary key) or `__rowid__`. Objects returned from the db always have rowid, but if you create the object yourself then you need to specify it if the object doesn't have a primary key. 103 | 104 | ### save_many 105 | ```py 106 | users = [User(name='user {n}', age=n) for n in range(10)] 107 | crud.save_many(*users) 108 | ``` 109 | While the `save_many` method takes an arbitrary number of objects, that could be just one, it's better to use `save_one` for single records. The main difference is that save one uses `driver.execute` and save many uses `drive.executemany` 110 | 111 | ### delete_one 112 | ``` 113 | crud.delete_one(obj) 114 | ``` 115 | Deletes a single record from the database. If the model does not have a primary key, or the object hasn't set its primary key or `__rowid__` this method will delete the first record that it finds with the given fields 116 | 117 | ```py 118 | class Item(Model): 119 | name: str 120 | category: str 121 | 122 | obj = Item(name='pen', category='office') 123 | 124 | crud = engine.crud(Item) 125 | 126 | crud.delete_one(obj) 127 | ``` 128 | In this snippet the `Item` model does not have a primary key set, so `delete_one` will delete any item that shares the same name and category. 129 | 130 | ### delete_many 131 | The query for deletion is created based on the objects ids, or rowids. 132 | If none is set then an exception will be raised `BadQueryError`. 133 | Works similarly to `delete_one` but the query is executed with inclusion like `"DELETE FROM item WHERE id IN (?, ?)"` where the placeholders are the ids of two objects. 134 | 135 | 136 | 137 | Crud objects that return data (insert and get) return instances of their models, so the User crud will only return instances of User because it will only interact with this table. 138 | 139 | If you require a more complex query you can use the engine directly, for example: 140 | 141 | 142 | ## Use with engine: 143 | 144 | The easiest way to use crud objects is letting the engine manage the crud's connection. The recommended way of using cruds is: 145 | ```py 146 | engine = Engine('db.sqlite') 147 | with engine: 148 | crud = engine.crud(YourModel) 149 | # work 150 | ``` 151 | 152 | ## Standalone use 153 | 154 | Crud objects actually only need a model type and an sqlite3 (or aiosqlite) connection to work so you can use them in a standalone way, but then make sure to manage correctly the conenction and close it when your program ends. 155 | 156 | ```py 157 | import sqlite3 158 | from ardilla import Crud 159 | crud = Crud(YourModel, sqlite3.connect('db.sqlite')) 160 | ``` 161 | 162 | ## additional methods 163 | 164 | ### count 165 | 166 | Count outputs an integer of the number of fields with non null values over a single column or the whole table. 167 | You can further restring the number of rows with key words 168 | 169 | ```py 170 | count = crud.count(age=35) 171 | # number of items in the table where the "age" column has the value 35 172 | ``` 173 | -------------------------------------------------------------------------------- /docs/guide/engine.md: -------------------------------------------------------------------------------- 1 | # Engines 2 | 3 | ## Basics 4 | 5 | Ardilla offers two engines, the default `ardilla.Engine` which is a sync client powered by `sqlite3` and `ardilla.asyncio.Engine` which is an asynchronous engine powered by `aiosqlite`. 6 | 7 | They expose the same interface except the async engine uses `await` its methods. 8 | 9 | To use the async engine you first need to install `aiosqlite` you can do it with any of the following methods: 10 | 11 | ``` 12 | pip install -U ardilla[async] 13 | pip install -U ardilla, aiosqlite 14 | ``` 15 | 16 | ## Use 17 | 18 | The engine manages the connections and cursors for the crud methods. 19 | 20 | The engine has the following parameters: 21 | 22 | - path: A pathlike that points to the location of the database 23 | - enable_foreing_keys: If true the engine will set `PRAGMA foreign_keys = ON;` in every connection. 24 | 25 | ```py 26 | from ardilla import Engine 27 | 28 | engine = Engine('path/to/database', enable_foreign_keys=True) 29 | 30 | ``` 31 | 32 | The engines work on single connections, and they can be used in two ways: 33 | 34 | ### classic open/close syntax 35 | With this interface you can create the engine in any context and connect and close it when your app stars/end. 36 | For the `AsyncEngine` you need `await` the methods. 37 | ```py 38 | engine = Engine('db.sqlite') 39 | engine.connect() # set up the connection 40 | # work 41 | engine.close() 42 | ``` 43 | 44 | ### contextmanager 45 | ```py 46 | with Engine('db.sqlite') as engine: 47 | # work 48 | ``` 49 | ## The CRUD objects 50 | 51 | The engines by themselves don't offer more than the connections but ardilla offers a CRUD class that has a one to one relation with Model subclasses. The Crud class requires the Model and engine as parameters so the engine offers a convenience method that works in three ways. 52 | 53 | `engine.crud(Model)` 54 | 55 | - returns the CRUD object for the specified Model. 56 | - keeps a CRUD object/model cache, so that crud models are only instantiated once and returned every time the method is called 57 | - calls the table creation to the database synchronously the first time the method is called. 58 | 59 | ## Using engine.crud 60 | 61 | ```py 62 | from ardilla import Engine, Model 63 | 64 | class User(Model): 65 | name: str 66 | 67 | with Engine('db.sqlite') as engine: 68 | user_crud = engine.crud(User) 69 | ``` 70 | 71 | 72 | ## Next 73 | 74 | The [CRUD](crud.md) object and how to use it... 75 | -------------------------------------------------------------------------------- /docs/guide/fields.md: -------------------------------------------------------------------------------- 1 | # Fields 2 | 3 | ## Basics 4 | 5 | Every model public attributes are automatically set as table columns. 6 | You can customize and extend the fields using `ardilla.Field` and `ardilla.ForeignField`. 7 | 8 | `ardilla.Field` is actually just a convenience import as it is actually `pydantic.Field`. 9 | 10 | `ardilla.ForeignField` is an instance of a callable class that serves as a helper for foreign key constrains. 11 | 12 | ## Usage 13 | 14 | To extend the functionality of an `ardilla.Model` import `ardilla.Field` and use it on your fields. 15 | The special keywords to use with fields are: 16 | 17 | - default: Sets the default value for the field 18 | - pk: sets the field as the primary key of the table 19 | - auto: sets the field to be autogenerated on insertion. It's only valid for these field types: 20 | - INTEGER: auto applies "autoincrement" and it's only available when used on private keys 21 | - DATETIME, TIME and DATE: auto applies "current_timestamp", "current_date" or "current_time" 22 | - **If auto was used, then the field is automatically not required.** 23 | - unique: sets the field's value to be unique in the table, it will rise conflic errors if breached 24 | 25 | ```py 26 | from datetime import datetime 27 | from ardilla import Model, Field 28 | 29 | class User(Model): 30 | id: int = Field(pk=True, auto=True) 31 | name: str = Field(unique=True) 32 | age: int # not null field 33 | money: float = 0.0 34 | created_date: datetime = Field(default_factory=datetime.utcnow, auto=True) 35 | ``` 36 | 37 | This Model will generate the following table schema: 38 | ```sql 39 | CREATE TABLE IF NOT EXISTS user( 40 | id INTEGER PRIMARY KEY AUTOINCREMENT, 41 | name TEXT NOT NULL UNIQUE, 42 | age INTEGER NOT NULL, 43 | money REAL DEFAULT 0.0, 44 | created_date DATETIME DEFAULT CURRENT_TIMESTAMP 45 | ); 46 | ``` 47 | Or, as a table: 48 | 49 | | id | name | age | money | created_date | 50 | |----|------|-----|-------|--------------| 51 | | 1 |chris | 35 | -10 | 1988-05-27-7 | 52 | 53 | 54 | ## Foreign key support 55 | 56 | To set fields with foreign keys, use the foreign field helper `ardilla.ForeignField` 57 | 58 | ```py 59 | from ardilla import Model, Field, ForeignField 60 | 61 | class Author(Model): 62 | id: int = Field(pk=True, auto=True) 63 | name: str 64 | 65 | class Book(Model): 66 | name: str 67 | author_id: int = ForeignField( 68 | references=Author, # the model with the referenced key 69 | on_delete=ForeignField.CASCADE, 70 | on_update=ForeignField.SET_NULL 71 | ) 72 | ``` 73 | This will generate the following schema for the Book model: 74 | ```sql 75 | CREATE TABLE IF NOT EXISTS book( 76 | name TEXT NOT NULL, 77 | author_id INTEGER NOT NULL, 78 | FOREIGN KEY (author_id) REFERENCES author(id) ON UPDATE SET NULL ON DELETE CASCADE 79 | ); 80 | ``` 81 | 82 | 83 | ## Next 84 | 85 | To put your models to use you'll need a an [engine](engine.md)... -------------------------------------------------------------------------------- /docs/guide/getting_started.md: -------------------------------------------------------------------------------- 1 | # Getting started 2 | 3 | ## Introduction 4 | 5 | Ardilla is specifically desiged to ease of use with intermedium developers in mind. 6 | The library help you abstract SQLite interactions in a simple pythonic way. 7 | 8 | There is a clear tradeoff in flexibility and performace. If you require more complex database designs or interactions or a more performant solution, please take a look at [alternative libraries](../ardilla_alternatives.md). 9 | 10 | ## Installation 11 | 12 | ``` 13 | pip install -U ardilla 14 | ``` 15 | 16 | ## desinging models 17 | 18 | ```py 19 | from datetime import datetime 20 | from ardilla import Model, Field, ForeignField 21 | 22 | class Author(Model): 23 | id: int = Field(pk=True, auto=True) 24 | name: str = Field(unique=True) 25 | 26 | # Author autogenerated schema: 27 | """ 28 | CREATE TABLE IF NOT EXISTS author( 29 | id INTEGER PRIMARY KEY AUTOINCREMENT, 30 | name TEXT NOT NULL UNIQUE 31 | ); 32 | """ 33 | 34 | class Book(Model): 35 | id: int = Field(pk=True, auto=True) 36 | name: str 37 | author_id: int = ForeignField( 38 | references=Author, 39 | on_delete=ForeignField.CASCADE, 40 | ) 41 | 42 | # Book autogenerated schema: 43 | """ 44 | CREATE TABLE IF NOT EXISTS book( 45 | id INTEGER PRIMARY KEY AUTOINCREMENT, 46 | name TEXT NOT NULL, 47 | author_id INTEGER NOT NULL, 48 | FOREIGN KEY (author_id) REFERENCES author(id) ON UPDATE NO ACTION ON DELETE CASCADE 49 | ); 50 | """ 51 | ``` 52 | 53 | ## Using the engine 54 | 55 | ```py 56 | from ardilla import Engine 57 | 58 | with Engine('path/to/db_file.sqlite3', enable_foreign_keys=True) as engine: 59 | author_crud = engine.crud(Author) 60 | book_crud = engine.crud(Book) 61 | 62 | ``` 63 | 64 | The crud objects hold all the logic to interact with a table in the database. 65 | They share a connection and are only good in the context where they're created. 66 | Alternatively you can use the regular open/close syntax 67 | 68 | ```py 69 | from ardilla import Engine 70 | 71 | engine = Engine('path/to/db_file.sqlite3', enable_foreign_keys=True) 72 | engine.connect() # always before creating cruds 73 | 74 | author_crud = engine.crud(Author) 75 | book_crud = engine.crud(Book) 76 | 77 | engine.close() # always remember to close your connections 78 | 79 | ``` 80 | 81 | ## Creating 82 | 83 | ```py 84 | 85 | book_data = { 86 | "William Gibson": ["Neuromancer", "Count Zero", "Mona Lisa Overdrive"], 87 | "Douglas Adams": ["The Hitchhiker's Guide to the Galaxy", "The Restaurant at the End of the Universe", "Life, the Universe and Everything", "So Long, and Thanks for All the Fish"], 88 | "George Orwell": ["1984", "Animal Farm", "Homage to Catalonia"], 89 | "Aldous Huxley": ["Brave New World", "Island", "Point Counter Point"] 90 | } 91 | 92 | for author_name, books in book_data.items(): 93 | author, was_created = author_crud.get_or_create(name=author_name) 94 | for book in books: 95 | book_crud.insert(name=book, author_id=author.id) 96 | 97 | ``` 98 | 99 | ## Reading 100 | ```py 101 | douglas_adams = author_crud.get_or_none(name='Douglas Adams') 102 | books_by_adams = book_crud.get_many(author_id=douglas_adams.id) 103 | print(books_by_adams) 104 | >>> [Book(id=4, name="The Hitchhiker's Guide to the Galaxy", author_id=2), Book(id=5, name='The Restaurant at the End of the Universe', author_id=2), Book(id=6, name='Life, the Universe and Everything', author_id=2), Book(id=7, name='So Long, and Thanks for All the Fish', author_id=2)] 105 | 106 | all_books = book_crud.get_all() 107 | 108 | ``` 109 | 110 | ## Updating 111 | 112 | ```py 113 | douglas_adams.name = douglas_adams.name.upper() 114 | 115 | author_crud.save_one(douglas_adams) 116 | 117 | ``` 118 | 119 | ## Deleting 120 | 121 | ```py 122 | author_crud.delete(douglas_adams) 123 | # we also delete all books linked to the author 124 | george_orwell_id = 3 125 | orwell_books = book_crud.get_many(author_id=george_orwell_id) 126 | # delete all orwell books 127 | book_crud.delete_many(*orwell_books) 128 | ``` 129 | 130 | ## CRUD Methods 131 | 132 | - `crud.insert` Inserts a record, rises errors if there's a conflict 133 | - `crud.insert_or_ignore` Inserts a record or silently ignores if it already exists 134 | - `crud.save_one` upserts an object 135 | - `crud.save_many` upserts many objects 136 | - `crud.get_all` equivalent to `SELECT * FROM tablename` 137 | - `crud.get_many` returns all the objects that meet criteria 138 | - `crud.get_or_create` returns an tuple of the object and a bool, True if the object was newly created 139 | - `crud.get_or_none` Returns the first object meeting criteria if any 140 | - `crud.delete_one` Deletes an object 141 | - `crud.delete_many` Deletes many objects 142 | 143 | 144 | ## Next 145 | 146 | Learn about how to build your own [Models](models.md) -------------------------------------------------------------------------------- /docs/guide/migration.md: -------------------------------------------------------------------------------- 1 | # Migration 2 | 3 | While Ardilla doesn't provide a migration manager like alembic (sqlalchemy) or aerich (tortoise) it does provide a simple migration script generator 4 | 5 | ## Use: 6 | 7 | Import the function and generate the script by passing the old and new models and the original tablename (what the tablename is in the database) 8 | 9 | ```python 10 | import sqlite3 11 | from ardilla import Model, Field, Engine 12 | from ardilla.migration import generate_migration_script 13 | 14 | class User(Model): 15 | id: int = Field(pk=True, auto=True) 16 | name: str 17 | foo: int 18 | 19 | class NewUser(Model): 20 | __tablename__ = 'users' # change the tablename 21 | id: int = Field(pk=True, auto=True) 22 | name: str = 'unset' # add a default 23 | age: int = 0 # add a new column "age" with default of "0" 24 | # drop the foo column 25 | 26 | 27 | migration_script = generate_migration_script( 28 | User, NewUser, 29 | original_tablename='user', 30 | new_tablename='users', 31 | ) 32 | 33 | con = sqlite3.connect('database.sqlite') 34 | con.executescript(migration_script) 35 | con.commit() 36 | con.close() 37 | ``` 38 | 39 | ## Limitations: 40 | 41 | The migration script generator can't handle adding foreign key fields, unique fields or adding a not null field without a default. 42 | 43 | -------------------------------------------------------------------------------- /docs/guide/models.md: -------------------------------------------------------------------------------- 1 | # Models 2 | 3 | ## Basics 4 | 5 | `ardilla.Model` inherits directly from `pydantic.BaseModel` and adds most of the added functionality through the `Model.__init_subclass__` method. 6 | 7 | On subclassing, the `Model` will grab the fields and private attributes and populate three private attributes: 8 | 9 | - `__schema__`: The SQLite table schema for the model. 10 | - `__pk__`: The private key column name if any. 11 | - `__tablename__`: The name of the table 12 | 13 | The user can set these fields by themselves to provide additional of special configurations Ardilla might not do on its own. 14 | 15 | To create a basic table you only need a single field and its type annotations. 16 | 17 | ```py 18 | from ardilla import Model 19 | 20 | class User(Model): 21 | name: str 22 | ``` 23 | 24 | This will create the following table schema: 25 | 26 | ```sql 27 | CREATE TABLE IF NOT EXISTS user( 28 | name TEXT NOT NULL 29 | ); 30 | ``` 31 | 32 | You can also set default values right away by specifying a value for the field 33 | 34 | 35 | ```py 36 | from ardilla import Model 37 | 38 | class User(Model): 39 | name: str = 'John Doe' 40 | ``` 41 | 42 | This will create the following table schema: 43 | 44 | ```sql 45 | CREATE TABLE IF NOT EXISTS user( 46 | name TEXT DEFAULT 'John Doe' 47 | ); 48 | ``` 49 | 50 | ## Customize table name 51 | 52 | By default the generated tablename is just the lowercase name of the model. 53 | you can edit the tablename by setting yourself this private attribute 54 | 55 | ```py 56 | from ardilla import Model 57 | 58 | class User(Model): 59 | __tablename__ = 'users' 60 | name: str = 'John Doe' 61 | ``` 62 | Will generate the schema: 63 | ```sql 64 | CREATE TABLE IF NOT EXISTS users( 65 | name TEXT DEFAULT 'John Doe' 66 | ); 67 | ``` 68 | 69 | ## Next 70 | 71 | To build more complex table models you need to learn about [Fields](fields.md)... -------------------------------------------------------------------------------- /docs/img/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisdewa/ardilla/312a373642ffc757634abf4d613d18477f562eb5/docs/img/favicon.ico -------------------------------------------------------------------------------- /docs/img/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisdewa/ardilla/312a373642ffc757634abf4d613d18477f562eb5/docs/img/logo.png -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # ardilla 2 | 3 | [![Downloads](https://static.pepy.tech/badge/ardilla/month)](https://pepy.tech/project/ardilla) ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/ardilla) ![PyPI](https://img.shields.io/pypi/v/ardilla) ![GitHub](https://img.shields.io/github/license/chrisdewa/ardilla) [![Documentation Status](https://readthedocs.org/projects/ardilla/badge/?version=latest)](https://ardilla.readthedocs.io/en/latest/?badge=latest) 4 | 5 |
6 | 9 |
10 | 11 | Ardilla (pronounced *ahr-dee-yah*) means "**SQ**uirre**L**" in spanish. 12 | 13 | This library aims to be a simple way to add an SQLite database and 14 | basic C.R.U.D. methods to python applications. 15 | It uses pydantic for data validation and supports a sync engine as well 16 | as an async (aiosqlite) version. 17 | 18 | ## Links 19 | 20 | Find Ardilla's source code [here](https://github.com/chrisdewa/ardilla) 21 | 22 | Documentation can be accessed [here](http://ardilla.rtfd.io/) 23 | 24 | ## Who and what is this for 25 | 26 | This library is well suited for developers seeking to incorporate SQLite into their python applications to use simple C.R.U.D. methods. 27 | It excels in its simplicity and ease of implementation while it may not be suitable for those who require more complex querying, intricate relationships or top performance. 28 | 29 | For developers who desire more advanced features, there are other libraries available, such as [tortoise-orm](https://github.com/tortoise/tortoise-orm), [sqlalchemy](https://github.com/sqlalchemy/sqlalchemy), [pony](https://github.com/ponyorm/pony) or [peewee](https://github.com/coleifer/peewee). 30 | 31 | 32 | ## Supported CRUD methods 33 | 34 | - `crud.insert` Inserts a record, rises errors if there's a conflict 35 | - `crud.insert_or_ignore` Inserts a record or silently ignores if it already exists 36 | - `crud.save_one` upserts an object 37 | - `crud.save_many` upserts many objects 38 | - `crud.get_all` equivalent to `SELECT * FROM tablename` 39 | - `crud.get_many` returns all the objects that meet criteria 40 | - `crud.get_or_create` returns an tuple of the object and a bool, True if the object was newly created 41 | - `crud.get_or_none` Returns the first object meeting criteria if any 42 | - `crud.delete_one` Deletes an object 43 | - `crud.delete_many` Deletes many objects 44 | 45 | 46 | ## Examples 47 | 48 | - A simple [FastAPI](https://github.com/chrisdewa/ardilla/blob/master/examples/fastapi_app.py) application 49 | - A reputation based discord [bot](https://github.com/chrisdewa/ardilla/blob/master/examples/rep_discord_bot.py) 50 | - [basic usage](https://github.com/chrisdewa/ardilla/blob/master/examples/basic_usage.py) 51 | - [basic usage with foreign keys](https://github.com/chrisdewa/ardilla/blob/master/examples/basic_usage_fk.py) 52 | 53 | 54 | ## Licence 55 | 56 | [Read Licence](licence.md) 57 | 58 | ## Next 59 | [Getting started](guide/getting_started.md)... -------------------------------------------------------------------------------- /docs/licence.md: -------------------------------------------------------------------------------- 1 | 2 | Copyright © 2023 ChrisDewa chrisdewa@duck.com 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 5 | 6 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 7 | 8 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 9 | -------------------------------------------------------------------------------- /examples/basic_usage.py: -------------------------------------------------------------------------------- 1 | # basic example 2 | 3 | from ardilla import Model, Engine, Field 4 | 5 | 6 | 7 | class Pet(Model): 8 | id: int = Field(pk=True, auto=True) 9 | name: str 10 | love: int = 0 11 | 12 | 13 | with Engine("foo.db") as engine: 14 | crud = engine.crud(Pet) 15 | 16 | # create a new pets 17 | for pet_name in {"fluffy", "fido", "snowball"}: 18 | crud.insert(name=pet_name) 19 | 20 | # read your pets 21 | fido = crud.get_or_none(name="fido") 22 | fluffy = crud.get_or_none(name="fluffy") 23 | snowball = crud.get_or_none(name="snowball") 24 | print(fido, fluffy, snowball, sep="\n") 25 | # update your pets 26 | fluffy.love += 10 27 | crud.save_one(fluffy) 28 | print(fluffy) 29 | # delete your pet 30 | crud.delete_many(fido, snowball) 31 | 32 | # check if everything works: 33 | pets = crud.get_all() 34 | assert len(pets) == 1, "Something went wrong!!" 35 | print("All done!") 36 | -------------------------------------------------------------------------------- /examples/basic_usage_fk.py: -------------------------------------------------------------------------------- 1 | # basic example 2 | 3 | from ardilla import Model, Engine, Field, ForeignField 4 | 5 | class Owner(Model): 6 | id: int = Field(pk=True, auto=True) 7 | name: str 8 | 9 | 10 | class Pet(Model): 11 | id: int = Field(pk=True, auto=True) 12 | name: str 13 | owner_id: int = ForeignField(references=Owner, on_delete=ForeignField.CASCADE) 14 | 15 | 16 | with Engine("foo.db", enable_foreing_keys=True) as engine: 17 | # create crud helpers 18 | owcrud = engine.crud(Owner) 19 | petcrud = engine.crud(Pet) 20 | 21 | # create owners 22 | chris = owcrud.insert(name='chris') 23 | liz = owcrud.insert(name='liz') 24 | 25 | # Create objects with relationships 26 | melly = petcrud.insert(name='melly', owner_id=liz.id) 27 | wolke = petcrud.insert(name='wolke', owner_id=chris.id) 28 | shirley = petcrud.insert(name='shirley', owner_id=chris.id) 29 | 30 | # delete owner and test CASCADING EFFECT 31 | owcrud.delete_one(chris) 32 | 33 | pets = petcrud.get_all() 34 | owners = owcrud.get_all() 35 | print(pets) 36 | print(owners) 37 | assert len(pets) == 1, "Foreign keys didn't cascade" 38 | print('All done, foreign key constrains work') 39 | -------------------------------------------------------------------------------- /examples/fastapi_app.py: -------------------------------------------------------------------------------- 1 | from typing import Annotated 2 | 3 | from fastapi import FastAPI, Depends, status, HTTPException 4 | from pydantic import BaseModel, Field 5 | 6 | from ardilla import Model 7 | from ardilla.asyncio import Engine 8 | from ardilla.errors import QueryExecutionError 9 | 10 | app = FastAPI(docs_url="/") # set the docs to index for easier access 11 | 12 | 13 | class Item(Model): 14 | id: int | None = Field( 15 | pk=True, auto=True 16 | ) # this sets the id as primary key in the default schema 17 | name: str 18 | price: float 19 | 20 | 21 | class PatchedItem(BaseModel): 22 | name: str 23 | price: float 24 | 25 | 26 | engine = Engine("fastapi_app.sqlite") 27 | 28 | 29 | @app.on_event("startup") 30 | async def on_startup_event(): 31 | await engine.connect() 32 | await engine.crud(Item) # cruds are cached, calling this here means 33 | # we don't lose instantiating it elsewhere 34 | 35 | @app.on_event("shutdown") 36 | async def on_shutdown_event(): 37 | await engine.close() 38 | 39 | async def get_item_by_id(id_: int) -> Item: 40 | """Returns the item with the specified id 41 | 42 | Args: 43 | id_ (int): the id of the item to lookup 44 | 45 | Raises: 46 | HTTPException: if there is no item with the given id_ 47 | 48 | """ 49 | crud =await engine.crud(Item) 50 | item = await crud.get_or_none(id=id_) 51 | if item is None: 52 | raise HTTPException( 53 | status_code=status.HTTP_404_NOT_FOUND, 54 | detail=f"No item with {id_} was found in the database", 55 | ) 56 | return item 57 | 58 | 59 | item_by_id_deps = Annotated[Item, Depends(get_item_by_id)] 60 | 61 | 62 | @app.post("/items/new") 63 | async def create_item(item: Item) -> Item: 64 | try: 65 | crud = await engine.crud(Item) 66 | new_item = await crud.insert(**item.dict()) 67 | except QueryExecutionError: 68 | raise HTTPException( 69 | status_code=status.HTTP_403_FORBIDDEN, 70 | detail=f"Item with {item.id} was already found in the database", 71 | ) 72 | return new_item 73 | 74 | 75 | @app.get("/items/{id}") 76 | async def get_item_route(item: item_by_id_deps) -> Item: 77 | return item 78 | 79 | 80 | @app.get("/items") 81 | async def get_all_items() -> list[Item]: 82 | crud = await engine.crud(Item) 83 | return await crud.get_all() 84 | 85 | 86 | @app.patch("/items/{id}") 87 | async def patch_item(item: item_by_id_deps, patched: PatchedItem) -> Item: 88 | item.name = patched.name 89 | item.price = patched.price 90 | crud = await engine.crud(Item) 91 | await crud.save_one(item) 92 | return item 93 | 94 | 95 | @app.delete("/item/{id}") 96 | async def delete_item(item: item_by_id_deps) -> None: 97 | crud = await engine.crud(Item) 98 | await crud.delete_one(item) 99 | 100 | 101 | if __name__ == "__main__": 102 | import uvicorn 103 | 104 | uvicorn.run(app) 105 | -------------------------------------------------------------------------------- /examples/rep_discord_bot.py: -------------------------------------------------------------------------------- 1 | from ardilla import Model, Field, ForeignField 2 | from ardilla.asyncio import Engine 3 | 4 | from discord import Intents, Member 5 | from discord.ext.commands import Bot, Context, guild_only 6 | 7 | # db engine 8 | engine = Engine("discobot.sqlite3", enable_foreing_keys=True) 9 | 10 | 11 | # models 12 | class GuildTable(Model): 13 | __tablename__ = "guilds" 14 | id: int = Field(primary=True) 15 | 16 | 17 | class MembersTable(Model): 18 | __tablename__ = "members" 19 | id: int 20 | guild_id: int = ForeignField( 21 | references=GuildTable, 22 | on_delete=ForeignField.CASCADE 23 | ) 24 | reputation: int = 0 25 | 26 | 27 | # bot stuff 28 | TOKEN = "GENERATE YOUR TOKEN FROM DISCORD'S DEVELOPERS' PORTAL" 29 | intents = Intents.default() 30 | intents.members = True 31 | intents.message_content = True 32 | 33 | class RepBot(Bot): 34 | def __init__(self): 35 | super().__init__(command_prefix='!', intents=intents) 36 | 37 | async def setup_hook(self): 38 | # connect the engine 39 | await engine.connect() 40 | # setup the table's cache 41 | self.gcrud = await engine.crud(GuildTable) 42 | self.mcrud = await engine.crud(MembersTable) 43 | 44 | async def close(self): 45 | # close engine 46 | await engine.close() 47 | return await super().close() 48 | 49 | bot = RepBot() 50 | 51 | 52 | @bot.command() 53 | @guild_only() 54 | async def thank(ctx: Context, member: Member): 55 | if member == ctx.author: 56 | return await ctx.send("You can't thank yourself") 57 | 58 | await bot.gcrud.insert_or_ignore(id=ctx.guild.id) 59 | dbmember, _ = await bot.mcrud.get_or_create(id=member.id, guild_id=ctx.guild.id) 60 | dbmember.reputation += 1 61 | await bot.mcrud.save_one(dbmember) 62 | await ctx.send( 63 | f"{member.mention} was thanked. Their reputation is now {dbmember.reputation}" 64 | ) 65 | 66 | 67 | @bot.command() 68 | @guild_only() 69 | async def reputation(ctx: Context, member: Member | None = None): 70 | member = member or ctx.author 71 | await bot.gcrud.insert_or_ignore(id=ctx.guild.id) 72 | dbmember, _ = await bot.mcrud.get_or_create(id=member.id, guild_id=ctx.guild.id) 73 | await ctx.send(f"{member.mention} has a reputation of {dbmember.reputation}") 74 | 75 | 76 | bot.run(TOKEN) 77 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | 2 | site_name: ardilla 3 | site_url: https://ardilla.rtfm.com/ 4 | site_author: ChrisDewa 5 | site_description: A simplistic and easy to use sqlite python ORM with pydantic models 6 | 7 | nav: 8 | - Guide: 9 | - guide/getting_started.md 10 | - guide/models.md 11 | - guide/fields.md 12 | - guide/engine.md 13 | - guide/crud.md 14 | - guide/migration.md 15 | - API Reference: 16 | - api_ref/crud.md 17 | - api_ref/model.md 18 | - api_ref/engine.md 19 | - Licence: licence.md 20 | - Changelog: changelog.md 21 | - Library Alternatives: ardilla_alternatives.md 22 | 23 | plugins: 24 | - mkdocstrings 25 | 26 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "ardilla" 7 | version = "0.4.0-beta" 8 | authors = [ 9 | { name="ChrisDewa", email="chrisdewa@duck.com" }, 10 | ] 11 | description = "Ardilla ORM. Easy to use, fast to implement, with sync and async flavors" 12 | readme = "README.md" 13 | requires-python = ">=3.9" 14 | 15 | classifiers = [ 16 | "Intended Audience :: Developers", 17 | "Programming Language :: Python :: 3 :: Only", 18 | "Programming Language :: Python :: 3.9", 19 | "Programming Language :: Python :: 3.10", 20 | "Programming Language :: Python :: 3.11", 21 | "License :: OSI Approved :: MIT License", 22 | "Operating System :: OS Independent", 23 | "Development Status :: 4 - Beta", 24 | ] 25 | dependencies = [ 26 | "pydantic==1.10.7", 27 | ] 28 | 29 | 30 | [project.optional-dependencies] 31 | async = ["aiosqlite==0.19.0",] 32 | 33 | examples = [ 34 | "fastapi==0.95.1", 35 | "uvicorn==0.22.0" 36 | ] 37 | 38 | dev = [ 39 | "pytest==7.3.1", # testing 40 | "pytest-asyncio==0.21.0", # testing async 41 | "black==23.3.0", # formating 42 | ] 43 | docs = [ 44 | "mkdocs==1.4.3", 45 | "jinja2<3.1.0", 46 | "mkdocstrings[python]==0.21.2", 47 | ] 48 | 49 | [project.urls] 50 | "Homepage" = "https://github.com/chrisdewa/ardilla" 51 | "Bug Tracker" = "https://github.com/chrisdewa/ardilla/issues" 52 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisdewa/ardilla/312a373642ffc757634abf4d613d18477f562eb5/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_async.py: -------------------------------------------------------------------------------- 1 | 2 | from contextlib import asynccontextmanager 3 | from pathlib import Path 4 | from functools import partial 5 | 6 | import pytest 7 | 8 | from ardilla import Model, Field, ForeignField 9 | from ardilla.asyncio import Engine 10 | from ardilla.errors import QueryExecutionError, DisconnectedEngine 11 | 12 | 13 | 14 | 15 | path = Path(__file__).parent 16 | db = path / "test_sync.sqlite" 17 | 18 | unlinkdb = partial(db.unlink, missing_ok=True) 19 | 20 | @asynccontextmanager 21 | async def cleanup(): 22 | unlinkdb() 23 | try: 24 | yield 25 | finally: 26 | unlinkdb() 27 | 28 | class User(Model): 29 | id: int = Field(pk=True, auto=True) 30 | name: str 31 | 32 | 33 | @pytest.mark.asyncio 34 | async def test_context_engine(): 35 | async with cleanup(): 36 | try: 37 | async with Engine(db) as engine: 38 | crud = await engine.crud(User) 39 | u = await crud.insert(name='chris') # should pass 40 | assert u.name == 'chris' 41 | await crud.insert(name='moni') 42 | except Exception as e: 43 | assert isinstance(e, DisconnectedEngine), f'Wrong exception raised' 44 | 45 | @pytest.mark.asyncio 46 | async def test_st_engine(): 47 | unlinkdb() 48 | try: 49 | engine = Engine(db) 50 | await engine.connect() 51 | crud = await engine.crud(User) 52 | u = await crud.insert(name='chris') # should pass 53 | assert u.name == 'chris' 54 | await engine.close() 55 | await crud.insert(name='moni') 56 | except Exception as e: 57 | assert isinstance(e, DisconnectedEngine), f'Wrong exception raised' 58 | finally: 59 | await engine.close() 60 | unlinkdb() 61 | 62 | 63 | # CREATE 64 | 65 | @pytest.mark.asyncio 66 | async def test_insert(): 67 | async with cleanup(), Engine(db) as engine: 68 | crud = await engine.crud(User) 69 | u = await crud.insert(name="chris") 70 | 71 | assert u is not None, "User wasn't created as expected" 72 | assert u.__rowid__ is not None, "Created user did not have __rowid__ set" 73 | assert u.__rowid__ == 1, "Created User did not have correct __rowid__ " 74 | try: 75 | await crud.insert(id=1, name="chris") 76 | except Exception as err: 77 | assert isinstance(err, QueryExecutionError), f'Wrong error rised: {err}' 78 | else: 79 | raise Exception("QueryExcecutionError should have been rised") 80 | 81 | @pytest.mark.asyncio 82 | async def test_insert_or_ignore(): 83 | async with cleanup(), Engine(db) as engine: 84 | crud = await engine.crud(User) 85 | kws = dict(id=1, name='chris') 86 | await crud.insert(**kws) 87 | u2 = await crud.insert_or_ignore(**kws) 88 | 89 | assert u2 is None 90 | 91 | 92 | @pytest.mark.asyncio 93 | async def test_save_one(): 94 | async with cleanup(), Engine(db) as engine: 95 | crud = await engine.crud(User) 96 | u = await crud.insert(name='chris') 97 | u.name = 'alex' 98 | await crud.save_one(u) 99 | 100 | user = await crud.get_or_none(name='alex') 101 | assert user.id == 1 102 | 103 | @pytest.mark.asyncio 104 | async def test_save_many(): 105 | users = [User(name=f'user {n}') for n in range(20)] 106 | async with cleanup(), Engine(db) as engine: 107 | crud = await engine.crud(User) 108 | await crud.save_many(*users) 109 | 110 | assert await crud.count() == 20 111 | 112 | # READ 113 | @pytest.mark.asyncio 114 | async def test_get_all(): 115 | async with cleanup(), Engine(db) as engine: 116 | crud = await engine.crud(User) 117 | for n in range(10): 118 | await crud.insert(name=f'user {n}') 119 | 120 | assert await crud.count() == 10 121 | 122 | @pytest.mark.asyncio 123 | async def test_get_many(): 124 | async with cleanup(), Engine(db) as engine: 125 | crud = await engine.crud(User) 126 | names = ['chris', 'moni', 'elena', 'fran'] 127 | for name in names: 128 | for _ in range(3): 129 | await crud.insert(name=name) 130 | 131 | assert await crud.count(name='chris') == 3 132 | 133 | @pytest.mark.asyncio 134 | async def test_get_or_create(): 135 | async with cleanup(), Engine(db) as engine: 136 | crud = await engine.crud(User) 137 | chris, created = await crud.get_or_create(name='chris') 138 | assert chris.id == 1 139 | assert created is True 140 | chris, created = await crud.get_or_create(name='chris') 141 | assert chris.id == 1 142 | assert created is False 143 | 144 | @pytest.mark.asyncio 145 | async def test_get_or_none(): 146 | async with cleanup(), Engine(db) as engine: 147 | crud = await engine.crud(User) 148 | chris = await crud.get_or_none(name='chris') 149 | assert chris is None 150 | await crud.insert(name='chris') 151 | chris = await crud.get_or_none(name='chris') 152 | assert chris is not None 153 | 154 | @pytest.mark.asyncio 155 | async def test_delete_one(): 156 | async with cleanup(), Engine(db) as engine: 157 | crud = await engine.crud(User) 158 | chrises = [User(name='chris') for _ in range(10)] 159 | await crud.save_many(*chrises) 160 | 161 | x = User(id=5, name='chris') 162 | await crud.delete_one(x) 163 | 164 | users = await crud.get_all() 165 | assert len(users) == 9 166 | assert all(u.id != 5 for u in users) 167 | 168 | @pytest.mark.asyncio 169 | async def test_delete_many(): 170 | async with cleanup(), Engine(db) as engine: 171 | crud = await engine.crud(User) 172 | users = [ 173 | User(id=n, name='chris') for n in range(10) 174 | ] 175 | await crud.save_many(*users) 176 | 177 | to_delete = users[:-1] 178 | await crud.delete_many(*to_delete) 179 | 180 | assert await crud.count() == 1, "Delete many didn't delete the correct amount of users" 181 | 182 | @pytest.mark.asyncio 183 | async def test_foreign_keys(): 184 | db = path / 'sync_test.sqlite' 185 | db.unlink(missing_ok=True) 186 | engine = Engine(db, enable_foreing_keys=True) 187 | await engine.connect() 188 | 189 | class Guild(Model): 190 | id: int = Field(pk=True, auto=True) 191 | name: str 192 | 193 | class User(Model): 194 | id: int = Field(pk=True, auto=True) 195 | name: str 196 | guild_id: int = ForeignField(references=Guild, on_delete=ForeignField.CASCADE) 197 | 198 | gcrud = await engine.crud(Guild) 199 | ucrud = await engine.crud(User) 200 | 201 | ga = await gcrud.insert(name='guild a') 202 | gb = await gcrud.insert(name='guild b') 203 | for guild in [ga, gb]: 204 | for n in range(5): 205 | await ucrud.insert(name=f'user {n}', guild_id=guild.id) 206 | 207 | assert await ucrud.count() == 10 208 | await gcrud.delete_one(ga) 209 | assert await ucrud.count() == 5 210 | await engine.close() 211 | db.unlink(missing_ok=True) 212 | 213 | -------------------------------------------------------------------------------- /tests/test_migration.py: -------------------------------------------------------------------------------- 1 | 2 | from pathlib import Path 3 | from typing import Optional 4 | from functools import partial 5 | from contextlib import contextmanager 6 | 7 | from ardilla import Field, Model, Engine 8 | from ardilla.migration import generate_migration_script 9 | 10 | db = Path(__file__).parent / 'test_db.sqlite3' 11 | unlink_db = partial(db.unlink, missing_ok=True) 12 | engine = Engine(db) 13 | 14 | @contextmanager 15 | def clean_db(): 16 | unlink_db() 17 | yield 18 | unlink_db() 19 | 20 | 21 | def test_tablename_change(): 22 | with clean_db(): 23 | class A(Model): 24 | field: str 25 | 26 | with engine: 27 | crud = engine.crud(A) 28 | crud.insert(field='something') 29 | 30 | class B(Model): 31 | field: str 32 | 33 | script = generate_migration_script( 34 | A, B, original_tablename='a', new_tablename='b' 35 | ) 36 | 37 | con = engine.get_connection() 38 | con.executescript(script) 39 | con.commit() 40 | 41 | cursor = con.cursor() 42 | 43 | # Execute the query to get table names 44 | cursor.execute("SELECT name FROM sqlite_master WHERE type='table';") 45 | 46 | # Fetch all the table names 47 | table_names = cursor.fetchall() 48 | cursor.close() 49 | con.close() 50 | 51 | assert table_names[0]['name'] == 'b' 52 | 53 | 54 | 55 | def test_full_migration(): 56 | """ 57 | Tests: 58 | - table rename 59 | - dropping columns 60 | - adding columns 61 | - changing column type 62 | """ 63 | with clean_db(): 64 | 65 | class User(Model): 66 | id: int = Field(pk=True, auto=True) 67 | name: str 68 | age: str 69 | glam: str = 'bling' 70 | 71 | with engine: 72 | crud = engine.crud(User) 73 | users = [User(name=f'user {n}', age=str(n)) for n in range(100)] 74 | crud.save_many(*users) 75 | 76 | class NewUser(Model): 77 | __tablename__ = 'users' 78 | id: int = Field(pk=True, auto=True) 79 | name: str 80 | age: int = 0 81 | pet: Optional[str] 82 | 83 | script = generate_migration_script( 84 | User, NewUser, original_tablename='user', new_tablename='users' 85 | ) 86 | 87 | con = engine.get_connection() 88 | con.executescript(script) 89 | con.commit() 90 | con.close() 91 | 92 | with engine: 93 | crud = engine.crud(NewUser) 94 | crud.insert(name='chris', age=35, pet='liu') 95 | 96 | db.unlink(missing_ok=True) 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | -------------------------------------------------------------------------------- /tests/test_models.py: -------------------------------------------------------------------------------- 1 | import sqlite3 2 | from pathlib import Path 3 | from datetime import datetime 4 | 5 | from ardilla import Model, Field 6 | from ardilla.errors import ModelIntegrityError 7 | 8 | from pydantic import Json 9 | 10 | 11 | def test_default_tablename(): 12 | class Foo(Model): 13 | id: int 14 | 15 | assert Foo.__tablename__ == "foo" 16 | 17 | def test_field_pk(): 18 | class Foo(Model): 19 | id: str = Field(primary=True) 20 | 21 | assert Foo.__pk__ == 'id' 22 | 23 | def test_int_pk_auto(): 24 | class Foo(Model): 25 | id: int = Field(pk=True, auto=True) 26 | 27 | schema = Foo.__schema__ 28 | assert 'id INTEGER PRIMARY KEY AUTOINCREMENT' in schema 29 | 30 | 31 | 32 | binary_data = b'some weird data' 33 | 34 | class Complex(Model): 35 | id: int = Field(pk=True, auto=True) 36 | created: datetime = Field(auto=True) 37 | name: str = 'me' 38 | lastname: str | None = None 39 | foo: str 40 | data: bytes = binary_data 41 | 42 | 43 | def test_default_schema(): 44 | complex_schema = f''' 45 | \rCREATE TABLE IF NOT EXISTS complex( 46 | \r id INTEGER PRIMARY KEY AUTOINCREMENT, 47 | \r created DATETIME DEFAULT CURRENT_TIMESTAMP, 48 | \r name TEXT DEFAULT 'me', 49 | \r lastname TEXT, 50 | \r foo TEXT NOT NULL, 51 | \r data BLOB DEFAULT (X'{binary_data.hex()}') 52 | \r); 53 | ''' 54 | assert Complex.__schema__.strip() == complex_schema.strip() 55 | 56 | 57 | def test_complex_schema_works(): 58 | try: 59 | db = Path(__file__).parent / 'db.sqlite3' 60 | db.unlink(missing_ok=True) 61 | con = sqlite3.connect(db) 62 | con.execute(Complex.__schema__) 63 | con.commit() 64 | finally: 65 | con.close() 66 | db.unlink(missing_ok=True) 67 | 68 | 69 | class User(Model): 70 | id: int = Field(primary=True) 71 | name: str 72 | 73 | 74 | tablename = "user" 75 | schema = """ 76 | CREATE TABLE IF NOT EXISTS user( 77 | \r id INTEGER PRIMARY KEY, 78 | \r name TEXT NOT NULL 79 | ); 80 | """ 81 | 82 | def test_default_schema(): 83 | assert User.__schema__.strip() == schema.strip() 84 | 85 | 86 | def test_pk(): 87 | assert User.__pk__ == "id" 88 | 89 | def test_double_pks(): 90 | try: 91 | class Book(Model): 92 | id: int = Field(pk=True) 93 | name: str = Field(pk=True) 94 | except Exception as e: 95 | assert isinstance(e, ModelIntegrityError) -------------------------------------------------------------------------------- /tests/test_sync.py: -------------------------------------------------------------------------------- 1 | import random 2 | import sqlite3 3 | from contextlib import contextmanager 4 | from pathlib import Path 5 | from functools import partial 6 | 7 | 8 | from ardilla import Engine, Model, Crud 9 | from ardilla.errors import QueryExecutionError, DisconnectedEngine 10 | from pydantic import Field 11 | 12 | from ardilla.fields import ForeignField 13 | 14 | 15 | path = Path(__file__).parent 16 | db = path / "test_sync.sqlite" 17 | 18 | unlinkdb = partial(db.unlink, missing_ok=True) 19 | 20 | @contextmanager 21 | def cleanup(): 22 | unlinkdb() 23 | try: 24 | yield 25 | finally: 26 | unlinkdb() 27 | 28 | class User(Model): 29 | id: int = Field(pk=True, auto=True) 30 | name: str 31 | 32 | def test_context_engine(): 33 | with cleanup(): 34 | try: 35 | with Engine(db) as engine: 36 | crud = engine.crud(User) 37 | u = crud.insert(name='chris') # should pass 38 | assert u.name == 'chris' 39 | crud.insert(name='moni') 40 | except Exception as e: 41 | assert isinstance(e, DisconnectedEngine), f'Wrong exception raised' 42 | 43 | def test_st_engine(): 44 | unlinkdb() 45 | try: 46 | engine = Engine(db) 47 | engine.connect() 48 | crud = engine.crud(User) 49 | u = crud.insert(name='chris') # should pass 50 | assert u.name == 'chris' 51 | engine.close() 52 | crud.insert(name='moni') 53 | except Exception as e: 54 | assert isinstance(e, DisconnectedEngine), f'Wrong exception raised' 55 | finally: 56 | engine.close() 57 | unlinkdb() 58 | 59 | 60 | # CREATE 61 | 62 | def test_insert(): 63 | with cleanup(), Engine(db) as engine: 64 | crud = engine.crud(User) 65 | u = crud.insert(name="chris") 66 | 67 | assert u is not None, "User wasn't created as expected" 68 | assert u.__rowid__ is not None, "Created user did not have __rowid__ set" 69 | assert u.__rowid__ == 1, "Created User did not have correct __rowid__ " 70 | try: 71 | crud.insert(id=1, name="chris") 72 | except Exception as err: 73 | assert isinstance(err, QueryExecutionError), f'Wrong error rised: {err}' 74 | else: 75 | raise Exception("QueryExcecutionError should have been rised") 76 | 77 | 78 | def test_insert_or_ignore(): 79 | with cleanup(), Engine(db) as engine: 80 | crud = engine.crud(User) 81 | kws = dict(id=1, name='chris') 82 | u1 = crud.insert(**kws) 83 | u2= crud.insert_or_ignore(**kws) 84 | 85 | assert u2 is None 86 | 87 | def test_save_one(): 88 | with cleanup(), Engine(db) as engine: 89 | crud = engine.crud(User) 90 | u = crud.insert(name='chris') 91 | u.name = 'alex' 92 | crud.save_one(u) 93 | 94 | user = crud.get_or_none(name='alex') 95 | assert user.id == 1 96 | 97 | 98 | def test_save_many(): 99 | users = [User(name=f'user {n}') for n in range(20)] 100 | with cleanup(), Engine(db) as engine: 101 | crud = engine.crud(User) 102 | crud.save_many(*users) 103 | 104 | assert crud.count() == 20 105 | 106 | # READ 107 | def test_get_all(): 108 | with cleanup(), Engine(db) as engine: 109 | crud = engine.crud(User) 110 | for n in range(10): 111 | crud.insert(name=f'user {n}') 112 | 113 | total = crud.count() 114 | assert total == 10 115 | 116 | def test_get_many(): 117 | with cleanup(), Engine(db) as engine: 118 | crud = engine.crud(User) 119 | names = ['chris', 'moni', 'elena', 'fran'] 120 | for name in names: 121 | for _ in range(3): 122 | crud.insert(name=name) 123 | 124 | chrises = crud.count(name='chris') 125 | 126 | assert chrises == 3 127 | 128 | def test_get_or_create(): 129 | with cleanup(), Engine(db) as engine: 130 | crud = engine.crud(User) 131 | chris, created = crud.get_or_create(name='chris') 132 | assert chris.id == 1 133 | assert created is True 134 | chris, created = crud.get_or_create(name='chris') 135 | assert chris.id == 1 136 | assert created is False 137 | 138 | def test_get_or_none(): 139 | with cleanup(), Engine(db) as engine: 140 | crud = engine.crud(User) 141 | chris = crud.get_or_none(name='chris') 142 | assert chris is None 143 | crud.insert(name='chris') 144 | chris = crud.get_or_none(name='chris') 145 | assert chris is not None 146 | 147 | def test_delete_one(): 148 | with cleanup(), Engine(db) as engine: 149 | crud = engine.crud(User) 150 | chrises = [User(name='chris') for _ in range(10)] 151 | crud.save_many(*chrises) 152 | 153 | x = User(id=5, name='chris') 154 | crud.delete_one(x) 155 | 156 | users = crud.get_all() 157 | assert len(users) == 9 158 | assert all(u.id != 5 for u in users) 159 | 160 | 161 | def test_delete_many(): 162 | with cleanup(), Engine(db) as engine: 163 | crud = engine.crud(User) 164 | users = [ 165 | User(id=n, name='chris') for n in range(10) 166 | ] 167 | crud.save_many(*users) 168 | 169 | to_delete = users[:-1] 170 | crud.delete_many(*to_delete) 171 | 172 | users = crud.get_all() 173 | assert len(users) == 1, "Delete many didn't delete the correct amount of users" 174 | 175 | 176 | def test_foreign_keys(): 177 | db = path / 'sync_test.sqlite' 178 | db.unlink(missing_ok=True) 179 | engine = Engine(db, enable_foreing_keys=True) 180 | engine.connect() 181 | 182 | class Guild(Model): 183 | id: int = Field(pk=True, auto=True) 184 | name: str 185 | 186 | class User(Model): 187 | id: int = Field(pk=True, auto=True) 188 | name: str 189 | guild_id: int = ForeignField(references=Guild, on_delete=ForeignField.CASCADE) 190 | 191 | gcrud = engine.crud(Guild) 192 | ucrud = engine.crud(User) 193 | 194 | ga = gcrud.insert(name='guild a') 195 | gb = gcrud.insert(name='guild b') 196 | for guild in [ga, gb]: 197 | for n in range(5): 198 | ucrud.insert(name=f'user {n}', guild_id=guild.id) 199 | 200 | assert ucrud.count() == 10 201 | gcrud.delete_one(ga) 202 | assert ucrud.count() == 5 203 | engine.close() 204 | db.unlink(missing_ok=True) 205 | 206 | 207 | 208 | --------------------------------------------------------------------------------