Add unique constraint, None option
This commit is contained in:
@@ -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
|
from .datalite_decorator import datalite
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Student:
|
||||||
|
id_: int
|
||||||
|
name: str
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
from dataclasses import Field
|
from dataclasses import Field
|
||||||
from typing import Any, Optional, Dict, List
|
from typing import Any, Optional, Dict, List
|
||||||
|
from .constraints import Unique
|
||||||
import sqlite3 as sql
|
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:
|
def _convert_type(type_: Optional[type], type_overload: Dict[Optional[type], str]) -> str:
|
||||||
"""
|
"""
|
||||||
Given a Python type, return the str name of its
|
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")
|
>>> _convert_sql_format("John Smith")
|
||||||
'"John Smith"'
|
'"John Smith"'
|
||||||
"""
|
"""
|
||||||
if isinstance(value, str):
|
if value is None:
|
||||||
|
return "NULL"
|
||||||
|
elif isinstance(value, str):
|
||||||
return f'"{value}"'
|
return f'"{value}"'
|
||||||
elif isinstance(value, bytes):
|
elif isinstance(value, bytes):
|
||||||
return '"' + str(value).replace("b'", "")[:-1] + '"'
|
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
|
sql_fields = "obj_id INTEGER PRIMARY KEY AUTOINCREMENT, " + sql_fields
|
||||||
cursor.execute(f"CREATE TABLE IF NOT EXISTS {class_.__name__.lower()} ({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"}
|
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,10 +2,12 @@
|
|||||||
Defines the Datalite decorator that can be used to convert a dataclass to
|
Defines the Datalite decorator that can be used to convert a dataclass to
|
||||||
a class bound to a sqlite3 database.
|
a class bound to a sqlite3 database.
|
||||||
"""
|
"""
|
||||||
|
from sqlite3.dbapi2 import IntegrityError
|
||||||
from typing import Dict, Optional, List, Callable
|
from typing import Dict, Optional, List, Callable
|
||||||
from dataclasses import Field, asdict
|
from dataclasses import Field, asdict
|
||||||
import sqlite3 as sql
|
import sqlite3 as sql
|
||||||
|
|
||||||
|
from constraints import ConstraintFailedError
|
||||||
from .commons import _convert_sql_format, _convert_type, _create_table, type_table
|
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()
|
table_name: str = self.__class__.__name__.lower()
|
||||||
kv_pairs = [item for item in asdict(self).items()]
|
kv_pairs = [item for item in asdict(self).items()]
|
||||||
kv_pairs.sort(key=lambda item: item[0]) # Sort by the name of the fields.
|
kv_pairs.sort(key=lambda item: item[0]) # Sort by the name of the fields.
|
||||||
|
try:
|
||||||
cur.execute(f"INSERT INTO {table_name}("
|
cur.execute(f"INSERT INTO {table_name}("
|
||||||
f"{', '.join(item[0] for item in kv_pairs)})"
|
f"{', '.join(item[0] for item in kv_pairs)})"
|
||||||
f" VALUES ({', '.join(_convert_sql_format(item[1]) for item in kv_pairs)});")
|
f" VALUES ({', '.join(_convert_sql_format(item[1]) for item in kv_pairs)});")
|
||||||
self.__setattr__("obj_id", cur.lastrowid)
|
self.__setattr__("obj_id", cur.lastrowid)
|
||||||
con.commit()
|
con.commit()
|
||||||
|
except IntegrityError:
|
||||||
|
raise ConstraintFailedError("A constraint has failed.")
|
||||||
|
|
||||||
|
|
||||||
def _update_entry(self) -> None:
|
def _update_entry(self) -> None:
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -6,6 +6,14 @@ datalite Module
|
|||||||
|
|
||||||
.. autodecorator:: datalite.datalite
|
.. autodecorator:: datalite.datalite
|
||||||
|
|
||||||
|
datalite.constraints module
|
||||||
|
----------------------------------
|
||||||
|
|
||||||
|
.. automodule:: datalite.constraints
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritence:
|
||||||
|
|
||||||
datalite.fetch module
|
datalite.fetch module
|
||||||
---------------------
|
---------------------
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ Documentation
|
|||||||
|
|
||||||
installation
|
installation
|
||||||
decorator
|
decorator
|
||||||
|
constraints
|
||||||
fetch
|
fetch
|
||||||
migration
|
migration
|
||||||
datalite
|
datalite
|
||||||
|
|||||||
@@ -4,3 +4,16 @@ Schema Migrations
|
|||||||
Datalite provides a module, ``datalite.migrations`` that handles schema migrations. When a class
|
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
|
definition is modified, ``datalite.migrations.basic_migration`` can be called to automatically
|
||||||
transfer records to a table fitting the new definitions.
|
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.
|
||||||
2
setup.py
2
setup.py
@@ -5,7 +5,7 @@ with open("README.md", "r") as fh:
|
|||||||
|
|
||||||
setuptools.setup(
|
setuptools.setup(
|
||||||
name="datalite", # Replace with your own username
|
name="datalite", # Replace with your own username
|
||||||
version="0.5.6",
|
version="0.5.7",
|
||||||
author="Ege Ozkan",
|
author="Ege Ozkan",
|
||||||
author_email="egeemirozkan24@gmail.com",
|
author_email="egeemirozkan24@gmail.com",
|
||||||
description="A small package that binds dataclasses to an sqlite database",
|
description="A small package that binds dataclasses to an sqlite database",
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import unittest
|
import unittest
|
||||||
|
from typing import Final
|
||||||
|
|
||||||
|
from constraints import ConstraintFailedError
|
||||||
from datalite import datalite
|
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 datalite.fetch import fetch_if, fetch_all, fetch_range, fetch_from, fetch_equals, fetch_where
|
||||||
from sqlite3 import connect
|
from sqlite3 import connect
|
||||||
from dataclasses import dataclass, asdict
|
from dataclasses import dataclass, asdict
|
||||||
@@ -40,10 +44,16 @@ class Migrate1:
|
|||||||
@datalite(db_path='test.db')
|
@datalite(db_path='test.db')
|
||||||
@dataclass
|
@dataclass
|
||||||
class Migrate2:
|
class Migrate2:
|
||||||
cardinal: int
|
cardinal: Unique[int] = 1
|
||||||
str_: str = "default"
|
str_: str = "default"
|
||||||
|
|
||||||
|
|
||||||
|
@datalite(db_path='test.db')
|
||||||
|
@dataclass
|
||||||
|
class ConstraintedClass:
|
||||||
|
unique_str: Unique[str]
|
||||||
|
|
||||||
|
|
||||||
def getValFromDB(obj_id = 1):
|
def getValFromDB(obj_id = 1):
|
||||||
with connect('test.db') as db:
|
with connect('test.db') as db:
|
||||||
cur = db.cursor()
|
cur = db.cursor()
|
||||||
@@ -163,5 +173,25 @@ class DatabaseMigration(unittest.TestCase):
|
|||||||
_drop_table('test.db', 'migrate1')
|
_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__':
|
if __name__ == '__main__':
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user