diff --git a/datalite/__init__.py b/datalite/__init__.py index a45fd71..4acb744 100644 --- a/datalite/__init__.py +++ b/datalite/__init__.py @@ -1,3 +1,12 @@ -__all__ = ['commons', 'datalite_decorator', 'fetch', 'migrations', 'datalite'] +__all__ = ['commons', 'datalite_decorator', 'fetch', 'migrations', 'datalite', 'constraints'] + +from dataclasses import dataclass + from .datalite_decorator import datalite + + +@dataclass +class Student: + id_: int + name: str diff --git a/datalite/commons.py b/datalite/commons.py index 2876db5..aafdfc8 100644 --- a/datalite/commons.py +++ b/datalite/commons.py @@ -1,8 +1,14 @@ from dataclasses import Field from typing import Any, Optional, Dict, List +from .constraints import Unique import sqlite3 as sql +type_table: Dict[Optional[type], str] = {None: "NULL", int: "INTEGER", float: "REAL", + str: "TEXT", bytes: "BLOB"} +type_table.update({Unique[key]: f"{value} NOT NULL UNIQUE" for key, value in type_table.items()}) + + def _convert_type(type_: Optional[type], type_overload: Dict[Optional[type], str]) -> str: """ Given a Python type, return the str name of its @@ -30,7 +36,9 @@ def _convert_sql_format(value: Any) -> str: >>> _convert_sql_format("John Smith") '"John Smith"' """ - if isinstance(value, str): + if value is None: + return "NULL" + elif isinstance(value, str): return f'"{value}"' elif isinstance(value, bytes): return '"' + str(value).replace("b'", "")[:-1] + '"' @@ -82,5 +90,5 @@ def _create_table(class_: type, cursor: sql.Cursor, type_overload: Dict[Optional sql_fields = "obj_id INTEGER PRIMARY KEY AUTOINCREMENT, " + sql_fields cursor.execute(f"CREATE TABLE IF NOT EXISTS {class_.__name__.lower()} ({sql_fields});") -type_table: Dict[Optional[type], str] = {None: "NULL", int: "INTEGER", float: "REAL", - str: "TEXT", bytes: "BLOB"} + + diff --git a/datalite/constraints.py b/datalite/constraints.py index e69de29..310ce3e 100644 --- a/datalite/constraints.py +++ b/datalite/constraints.py @@ -0,0 +1,26 @@ +""" +datalite.constraints module introduces constraint + types that can be used to hint field variables, + that can be used to signal datalite decorator + constraints in the database. +""" +from typing import TypeVar, Union, Tuple + +T = TypeVar('T') + + +class ConstraintFailedError(Exception): + """ + This exception is raised when a Constraint fails. + """ + pass + + +""" +Dataclass fields hinted with this type signals + datalite that the bound column of this + field in the table is NOT NULL and UNIQUE. +""" +Unique = Union[Tuple[T], T] + + diff --git a/datalite/datalite_decorator.py b/datalite/datalite_decorator.py index f72ec15..78a0a04 100644 --- a/datalite/datalite_decorator.py +++ b/datalite/datalite_decorator.py @@ -2,10 +2,12 @@ Defines the Datalite decorator that can be used to convert a dataclass to a class bound to a sqlite3 database. """ - +from sqlite3.dbapi2 import IntegrityError from typing import Dict, Optional, List, Callable from dataclasses import Field, asdict import sqlite3 as sql + +from constraints import ConstraintFailedError from .commons import _convert_sql_format, _convert_type, _create_table, type_table @@ -22,11 +24,14 @@ def _create_entry(self) -> None: table_name: str = self.__class__.__name__.lower() kv_pairs = [item for item in asdict(self).items()] kv_pairs.sort(key=lambda item: item[0]) # Sort by the name of the fields. - cur.execute(f"INSERT INTO {table_name}(" - f"{', '.join(item[0] for item in kv_pairs)})" - f" VALUES ({', '.join(_convert_sql_format(item[1]) for item in kv_pairs)});") - self.__setattr__("obj_id", cur.lastrowid) - con.commit() + try: + cur.execute(f"INSERT INTO {table_name}(" + f"{', '.join(item[0] for item in kv_pairs)})" + f" VALUES ({', '.join(_convert_sql_format(item[1]) for item in kv_pairs)});") + self.__setattr__("obj_id", cur.lastrowid) + con.commit() + except IntegrityError: + raise ConstraintFailedError("A constraint has failed.") def _update_entry(self) -> None: diff --git a/docs/constraints.rst b/docs/constraints.rst index e69de29..992d579 100644 --- a/docs/constraints.rst +++ b/docs/constraints.rst @@ -0,0 +1,67 @@ +Constraints +================ + +One of the most useful features provided by SQLite is the concept of +*constraints*. Constraints signal the SQL engine that the values hold in a +specific column **MUST** abide by specific constraints, these might be + +* Values of this column cannot be ``NULL``. (``NOT NULL``) +* Values of this column cannot be repeated. (``UNIQUE``) +* Values of this column must fulfill a condition. (``CHECK``) +* Values of this column can be used to identify a record. (``PRIMARY``) +* Values of this column has a default value. (``DEFAULT``) + +Some of these constraints are already implemented in datalite. With all of the set, +is planned to be implemented in the future. + +Default Values +--------------- + +Columns can be given default values. This is done the same way you would give a +datafield a default value. + +.. code-block:: python + + @datalite("db.db") + @dataclass + class Student: + id_: int + name: str = "Albert Einstein" + +Therefore, from now on, any ``Student`` object, whose name is not specified, will +have the default name ``"Albert Einstein"`` and if ``.create_entry()`` method is +called on them, the newly inserted record will, by default, have this value in its +corresponding column. + +Unique Values +-------------- + +Declaring a field unique is done by a special ``TypeVar`` called ``Unique`` +this uniqueness check is done in the database level, this introduces has some pros, +but also some cons. + +Pushing the uniqueness check to the database level introduces a better ability to +handle concurrency for applications with large traffic, however, this check only +runs when an item is registered, which means no problem will raise in +the object creation *even if* another object of the same type with the same value +hold in the unique field exists, no error will raise. However, if another *record* +with the same value in the unique field is recorded in the bound database, upon +the invocation of the ``.create_entry()`` will raise the ``ConstraintFailedError`` +exception. + +Uniqueness constraint is declared thusly: + +.. code-block:: python + + @datalite("db.db") + @dataclass + class Student: + id_: Unique[int] + name: str = "Albert Einstein" + +Hinting a field with the ``Unique[T]`` type variable will introduce two rules: + +#. The values in the column of the field in the table that represents the dataclass ``Student`` in the bound database ``db.db`` cannot be NULL, thus the corresponding field cannot be assigned ``None``. +#. These same values **must** be unique for each and every record. + +Failure of any of these two rules will result in a ``ConstraintFailedError`` exception. diff --git a/docs/datalite.rst b/docs/datalite.rst index 66a1f24..05d1711 100644 --- a/docs/datalite.rst +++ b/docs/datalite.rst @@ -6,6 +6,14 @@ datalite Module .. autodecorator:: datalite.datalite +datalite.constraints module +---------------------------------- + +.. automodule:: datalite.constraints + :members: + :undoc-members: + :show-inheritence: + datalite.fetch module --------------------- diff --git a/docs/index.rst b/docs/index.rst index 236e845..5b108a9 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -19,6 +19,7 @@ Documentation installation decorator + constraints fetch migration datalite diff --git a/docs/migration.rst b/docs/migration.rst index e08058f..3310705 100644 --- a/docs/migration.rst +++ b/docs/migration.rst @@ -4,3 +4,16 @@ Schema Migrations Datalite provides a module, ``datalite.migrations`` that handles schema migrations. When a class definition is modified, ``datalite.migrations.basic_migration`` can be called to automatically transfer records to a table fitting the new definitions. + +Let us say we have made changes to the fields of a dataclass called ``Student`` and now, +we want these changes to be made to the database. More specifically, we had a field called +``studentt_id`` and realised this was a typo, we want it to be named into ``student_id``, +and we want the values that was previously hold in this column to be persistent despite the +name change. We can achieve this easily by: + +.. code-block:: python + + datalite.basic_migration(Student, {'studentt_id': 'student_id'}) + +This will make all the changes, if we had not provided the second argument, +the values would be lost. \ No newline at end of file diff --git a/setup.py b/setup.py index 5adec55..e881225 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ with open("README.md", "r") as fh: setuptools.setup( name="datalite", # Replace with your own username - version="0.5.6", + version="0.5.7", author="Ege Ozkan", author_email="egeemirozkan24@gmail.com", description="A small package that binds dataclasses to an sqlite database", diff --git a/test/main_tests.py b/test/main_tests.py index 54c3123..b78ed5b 100644 --- a/test/main_tests.py +++ b/test/main_tests.py @@ -1,5 +1,9 @@ import unittest +from typing import Final + +from constraints import ConstraintFailedError from datalite import datalite +from datalite.constraints import Unique from datalite.fetch import fetch_if, fetch_all, fetch_range, fetch_from, fetch_equals, fetch_where from sqlite3 import connect from dataclasses import dataclass, asdict @@ -40,10 +44,16 @@ class Migrate1: @datalite(db_path='test.db') @dataclass class Migrate2: - cardinal: int + cardinal: Unique[int] = 1 str_: str = "default" +@datalite(db_path='test.db') +@dataclass +class ConstraintedClass: + unique_str: Unique[str] + + def getValFromDB(obj_id = 1): with connect('test.db') as db: cur = db.cursor() @@ -163,5 +173,25 @@ class DatabaseMigration(unittest.TestCase): _drop_table('test.db', 'migrate1') +def helperFunc(): + obj = ConstraintedClass("This string is supposed to be unique.") + obj.create_entry() + + +class DatabaseConstraints(unittest.TestCase): + def setUp(self) -> None: + self.obj = ConstraintedClass("This string is supposed to be unique.") + self.obj.create_entry() + + def testUniquness(self): + self.assertRaises(ConstraintFailedError, helperFunc) + + def testNullness(self): + self.assertRaises(ConstraintFailedError, lambda : ConstraintedClass(None).create_entry()) + + def tearDown(self) -> None: + self.obj.remove_entry() + + if __name__ == '__main__': unittest.main()