Advanced usage
Fields declaration
Types
Python |
pSQL |
SQLite |
int |
integer |
INTEGER |
float |
double precision |
REAL |
str |
text |
TEXT |
bytes |
bytea |
BLOB |
datetime |
timestamp |
DATETIME |
time |
time |
TIME |
date |
date |
DATE |
timedelta |
interval |
TIMEDELTA |
bool |
boolean |
BOOLEAN |
dict |
jsonb |
JSON |
UUID |
uuid |
UUID |
IntEnum |
integer |
INTEGER |
StrEnum |
text |
TEXT |
Declaration
from quazy import DBTable, DBField, Optional
# schema name for all tables in this module
# "public" by default, if not specified
_SCHEMA_ = "playground"
class Customer(DBTable):
# just a simple field definition, required by default (couldn't be None)
simply_name: str
# field could be None
required_prop: Optional[str]
required_alt: str = DBField(required=False)
# indexed for fast search
lookup_field: str = DBField(indexed=True)
unique_field: str = DBField(unique=True)
# default values
predef_simple: str = "ABC"
predef_alt: str = DBField(default="ABC")
predef_lambda: str = lambda x: x.simply_name + "XYZ"
predef_by_sql: str = DBField(default_sql="now()")
Enumerated types
Just use builtin types: IntEnum and StrEnum
from quazy import DBTable, IntEnum
class Customer(DBTable):
class Level(IntEnum):
BASIC = 1
VERIFIED = 2
VIP = 10
name: str
level: Customer.Level = Customer.Level.BASIC
db.insert(Customer(name="John", level=Customer.Level.VIP))
Primary key
It is supposed that each table has it’s own primary key, even if it’s not explicitly declared.
In cases you don’t bother, Quazy can create id: int key field implicitly.
- Note:
There is only one primary key per data table supported yet.
from quazy import DBTable, DBField
class Customer(DBTable):
# there is implicit declaration of `id` field
name: str
class CustomerWithId(DBTable):
myid: int = DBField(pk=True)
name: str
class CustomerWithUUID(DBTable):
# UUID type is also supported
uid: UUID = DBField(pk=True)
name: str
Referenced tables
One-to-many relations
class Box(DBTable):
location: str
class Fruit(DBTable):
name: str
box: Box
box1 = Box(location="Top left corner").save()
box2 = Box(location="Top right corner").save()
Fruit(name="apple", box=box1).save()
box2.fruits.append(Fruit(name="banana", box=box2))
box2.save()
for x in Fruit.select("name", location=lambda x: x.box.location):
print(x)
for x in Box.select("location", fruit=lambda x: x.fruits.name):
print(x)
In the example above Box object has implicit field fruits, having all fruits in it.
It is also possible specify “reverse” field name explicitly.
class User(DBTable):
name: str
class Task(DBTable):
title: str
sender: User = DBField(reverse_name="tasks_send")
receiver: User = DBField(reverse_name="tasks_received")
j = User(name="John").save()
b = User(name="Bob").save()
Task(title="Task 1", sender=b, receiver=j).save()
Task(title="Task 2", sender=b, receiver=j).save()
Task(title="Task 3", sender=j, receiver=b).save()
q = User.select("name", task=lambda x: x.tasks_received.title).where(name="John")
print(q.fetch_all(as_dict=True))
u = User.get(name="John").load("tasks_received")
for t in u.tasks_received:
print(t.title)
# alternativery via `fetch`
u = User.get(name="John")
for t in u.tasks_received.fetch():
print(t.title)
Many-to-many relations
class Book(DBTable):
name: str
sellers: 'Many[Seller]'
class Seller(DBTable):
name: str
books: Many[Book]
b1 = Book(name="Alice in wonderland").save()
b2 = Book(name="Rust for noobies").save()
b3 = Book(name="Backside of the life").save()
s1 = Seller(name="Fancy Books").save()
s2 = Seller(name="Alibazon").save()
s3 = Seller(name="Booksabon").save()
s1.books.append(b1)
s1.books.append(b3)
s1.save()
s2.books.append(b2)
s2.books.append(b3)
s2.save()
s3.books.append(b1)
s3.books.append(b2)
s3.save()
q = Book.select(seller="sellers.name").filter(name="Alice in wonderland")
print(q.fetch_list())
Substitute tables
Subtables - are tables dependent on tables in 1xN relation. Usually, it is simply declared as two tables classes
with a reference field.
class Receipt(DBTable):
created_at: datetime = DBField(default_sql="now()")
items: 'Many[ReceiptItem]' # <- helpers field
class ReceiptItem(DBTable):
receipt: Receipt # <- reference field makes relation 1xN
name: str
price: float
qty: float
total: float = DBField(default=lambda x: x.price * x.qty)
It is possible to declare simpler using subclasses:
class Receipt(DBTable):
created_at: datetime = DBField(default_sql="now()")
class Item(DBTable):
name: str
price: float
qty: float
total: float = DBField(default=lambda x: x.price * x.qty)
r = Receipt()
r.items.append(Receipt.Item(name="Cheese", price=1100, qty=0.1))
r.items.append(Receipt.Item(name="Sausage", price=160, qty=0.5))
r.items.append(Receipt.Item(name="Milk", price=56, qty=1))
db.insert(r)
q = Receipt.Item.select("name", "total").filter(receipt=r)
print("Total sum:", q.fetch_sum("total"))
print("Items:", ", ".join(q.fetch_list()))
Joined Table Inheritance
Let’s imaging you have several catalogs with it’s own specific fields, but the same logic processing, storing and
presentation. It’s usually a normal practice to store all such catalogs in one physical table.
It is supposed that one additional field must be provided, with table identifier, to separate datas.
In QuazyDB such field is provided via FieldCID[] annotation. Actual type could be any, but if it is a string, QuazyDB
engine fill it by table class name by default. Otherwise, _discriminator_ value should be provided.
Hint
Query engine deals with this logic implicitly, adding proper folter to discriminator field for any requests.
In the example below only one table created in database, named catalog.
class Catalog(DBTable):
_extendable_ = True
cid: FieldCID[str]
number: int
class Supplier(Catalog):
name: str
agreement: str | None
class Customer(Catalog):
name: str
start_date: datetime
vip_class: int | None
Supplier(number=99, name="Golden nuts").save()
Customer(number=56, name="Hungry mouse").save()
print(Supplier.select("name").fetch_list())
print(Customer.select("name").fetch_list())
It also works with subtables, with only one exception: FieldCID is not necessary to declare
for substitute classes:
class Catalog(DBTable):
_extendable_ = True
cid: FieldCID[str]
number: int
class Row(DBTable):
_extendable_ = True
item: str
class Customer(Catalog):
name: str
start_date: datetime
vip_class: int | None
class Row(Catalog.Row):
shipment: str
def __repr__(self):
return f'delivery {self.item} -> to {self.shipment}'
c = Customer(number=56, name="Hungry mouse")
c.rows.append(Customer.Row(item="milk", shipment="storage"))
c.save()
print(Customer.get(number=56).rows.fetch())
Lightweight JSON properties
Every modern database engine has a support to JSON field types. More then that, it gives rich features to use
in-JSON fields in SQL queries for sophisticated selections and filters.
Why should we care?
Let’s imagine you have users registry on your social networking platform. How many fields you have to add to your
User table to satisfy all needs? You can’t be sure about amount, but you are pretty sure that every little change
to a database could a painful enough. So, why don’t you just put all user-specific fields in one JSON field and
forget about any migration pain ever?
QuazyDB is intruduced special annotation generic Property[], which points that field belongs to JSON structure.
It is also obligated to specify special body (or any other name) field with type BodyField.
Note
Property can not be marked as required, because it is in the essence of it’s dynamic nature.
It also can not have default_sql value.
# this table is created with only one column `body`
class Journal(NamedTable):
body: FieldBody
title: Property[str]
price: Property[float]
pub_date: Property[datetime]
Journal(title="Xakep", price=9.99, pub_date=datetime(2010, 1, 10)).save()
Journal.get(title="Xakep").inspect()
IDE-friendly code completion
- To code even more faster, there are several IDE friendly tricks performed:
Query object is based on Generic[T], where T is a specific DBTable class. It helps to access table fields
for query results.
There are many fields implicitly created. To make it visible, generate stub pyi helper file.
Stub files also describe constructor arguments names.
from quazy.stub import gen_stub
# generate stub file
with open("test.pyi", "wt") as f:
f.write(gen_stub(db))
There is an example of generated stub:
Source code
class User(DBTable):
name: str
class Task(DBTable):
title: str
sender: User = DBField(reverse_name="tasks_sent")
receiver: User = DBField(reverse_name="tasks_received")
class History(DBTable):
record_date: datetime = DBField(default_sql="now()")
description: str | None
Stub file
class User(DBTable):
name: str
id: int
tasks_sent: list["Task"]
tasks_received: list["Task"]
def __init__(self, name: str = None, id: int = None, tasks_send: list["Task"] = None, tasks_received: list["Task"] = None): ...
class Task(DBTable):
title: str
sender: "User"
receiver: "User"
id: int
historys: list["Task.History"]
def __init__(self, title: str = None, sender: "User" = None, receiver: "User" = None, id: int = None, historys: list["Task.History"] = None): ...
class History(DBTable):
record_date: datetime | None
description: str | None
id: int
task: "Task"
def __init__(self, record_date: datetime = None, description: str = None, id: int = None, task: "Task" = None): ...
Migrations
Migration means put any modifications to the database schema. Quazy can analyze your object models and keep
data schema updated.
It is allowed to apply and revert any modification, moving via modifications tree.
There is additional table named migration created in schema migrations in the database when activated.
Each row contains migration index, schema tables snapshot and necessary commands list to perform changes
in the database.
Example
Initial migration
class SomeTable(DBTable):
name: str
db = DBFactory.postgres(conninfo="postgresql://quazy:quazy@127.0.0.1/quazy")
db.bind_module()
db.clear()
db.create()
activate_migrations(db)
diff = compare_schema(db)
print("Initial:")
print(diff.info())
apply_changes(db, diff)
Any new migration
class SomeTable(DBTable):
name: str
value: int
diff = compare_schema(db)
print(diff.info())
apply_changes(db, diff)
Revert migration
diff = compare_schema(db, migration_index="0001")
print(diff.info())
apply_changes(db, diff)
Rename table or field
class AwesomeTable(DBTable):
name: str
integer_value: int
diff = compare_schema(db, [("SomeTable", "AwesomeTable"), ("value", "integer_value")])
print(diff.info())
apply_changes(db, diff)
Warning
Use renaming for refactoring and misspells correction to avoid deletion and possible data lost.
Data validation
It is possible to enable data validation with pydantic module. Validation is enabled by default if this module is
installed, otherwise, QuazyDB should be installed by specifying this explicitly:
pip install quazydb[strict]
Validation example:
class File(DBTable):
name: str
size: int
class FileDanger(DBTable):
_validate_ = False
name: str
size: int
File(name="test.txt", size=1024)
# this is good
File(name="test2.txt", size='1024')
# this is also good, because '1024' is a valid number (by powers of `pydantic`)
File(name=123, size='1024')
# quazy.exceptions.QuazyFieldTypeError: Field `name` in `File` has wrong type: 1 validation error for str
# Input should be a valid string [type=string_type, input_value=123, input_type=int]
FileDanger(name=123, size='1024')
# this is ok (take your own care for saving to database)
Evaluated fields
There is an opportunity to create evaluated fields, just in case storing is not needed.
class Test(DBTable):
a: int
b: int
# regular field, defined as property for runtime evaluation
@property
def c(self) -> int:
return self.a + self.b
# field, defined as static method for SQL evaluation, always postfixed with `__view`
# exposed with query builder only
@staticmethod
def c__view(x: DBQueryField[Test]) -> DBQuery:
return x.a + x.b
# another field, defined as property, but without mapped view function
# if this expression is query compatible - query builder will call it implicitly
@property
def d(self) -> int:
return self.a * self.b
t = Test(a=1, b=2).save()
print(t.c, t.d)
# `c` is selected by `c__view` method and `d` is selected by property expression
t2 = Test.select("c", "d").filter(lambda x: (x.c == 3) & (x.d == 2)).fetch_one()
print(t2.c, t2.d)
There is SQL generated:
SELECT
"test".a+"test".b AS "c",
"test".a*"test".b AS "d"
FROM "public"."test" AS "test"
WHERE
"test".a+"test".b=%(_arg_1)s AND "test".a*"test".b=%(_arg_2)s
Class variables
Quazy ignores class variables annotated as ClassVar[], and ObjVar[] also.
class Config(DBTable):
obj: ClassVar['Config'] = None
data: dict
@classmethod
def __class_getitem__(cls, item):
if cls.obj is None:
cls.obj = Config.query().fetch_one()
if cls.obj is None:
cls.obj = Config(data={'last_request': datetime.now()}).save()
return cls.obj.data[item]
@classmethod
def __class_setitem__(cls, item, value):
cls.obj.date[item] = value
cls.obj.save()
class Statefull(DBTable):
a: int
b: int
state: ObjVar[str]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.state = "pre-processing"
Note, that Config class declaration above is just an example not a recomended practice. Better approach is to use
properties.
class Config(DBTable):
data: Body
last_request: Property[datetime] = lambda x: datetime.now()
c = Config().save()
print(c.last_request)
Use slots
To reduce memory consumption QuazyDB supports Python’s __slots__ system. It is optional and has usage limits.
The most important limitation in ability to use reversed fields and many-to-many fields, as it is evaluated on
second pass after all classes are materialized.
class Order(DBTable):
name: str
class Row(DBTable):
_use_slots_ = True
order: Order
o = Order(name="test")
o.rows.append(Row())
# it is OK
Otherwise:
class Order(DBTable):
_use_slots_ = True
name: str
class Row(DBTable):
order: Order
o = Order(name="test")
o.rows.append(Row())
# AttributeError: 'Order' object has no attribute 'rows'