├── .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 | [](https://pepy.tech/project/ardilla)    [](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 | [](https://pepy.tech/project/ardilla)    [](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 |
--------------------------------------------------------------------------------