Contenttypes¶
Intro¶
Relational database systems work with the concept of tables. Tables are independent of each other except for foreign keys which works nice in most cases but this design has a little drawback.
Querying, iterating generically across tables and domains is hard, this is where ContentTypes come in play. ContentTypes abstract all the tables in one table, which is quite powerful. By having only one table with back links to all the other tables, it is possible to have generic tables which logic applies to all other tables. Normally you can only enforce uniqueness per table, now it this possible via the ContentType table for data in different tables (you just have to compress them usefully e.g. by a hash).
import edgy
database = edgy.Database("sqlite:///db.sqlite")
models = edgy.Registry(database=database, with_content_type=True)
class Person(edgy.Model):
first_name = edgy.fields.CharField(max_length=100)
last_name = edgy.fields.CharField(max_length=100)
class Meta:
registry = models
unique_together = [("first_name", "last_name")]
class Organisation(edgy.Model):
name = edgy.fields.CharField(max_length=100, unique=True)
class Meta:
registry = models
class Company(edgy.Model):
name = edgy.fields.CharField(max_length=100, unique=True)
class Meta:
registry = models
async def main():
async with database:
await models.create_all()
person = await Person.query.create(first_name="John", last_name="Doe")
org = await Organisation.query.create(name="Edgy org")
comp = await Company.query.create(name="Edgy inc")
# we all have the content_type attribute and are queryable
assert await models.content_type.query.count() == 3
edgy.run_sync(main())
Implementation
Because we allow all sorts of primary keys we have to inject an unique field in every model to traverse back.
Example: The art of comparing apples with pears¶
Let's imagine we have to compare apples with pears via weight. We want only fruits with different weights. Because weight is a small number we just can put it in the collision_key field of ContentType.
import asyncio
from sqlalchemy.exc import IntegrityError
import edgy
database = edgy.Database("sqlite:///db.sqlite")
models = edgy.Registry(database=database, with_content_type=True)
class Apple(edgy.Model):
g = edgy.fields.SmallIntegerField()
class Meta:
registry = models
class Pear(edgy.Model):
g = edgy.fields.SmallIntegerField()
class Meta:
registry = models
async def main():
async with database:
await models.create_all()
await Apple.query.bulk_create(
[{"g": i, "content_type": {"collision_key": str(i)}} for i in range(1, 100, 10)]
)
apples = [
await asyncio.create_task(content_type.get_instance())
async for content_type in models.content_type.query.filter(name="Apple")
]
try:
await Pear.query.bulk_create(
[{"g": i, "content_type": {"collision_key": str(i)}} for i in range(1, 100, 10)]
)
except IntegrityError:
pass
pears = [
await asyncio.create_task(content_type.get_instance())
async for content_type in models.content_type.query.filter(name="Pear")
]
assert len(pears) == 0
edgy.run_sync(main())
If we know we compare over all domains just weight, we can even replace the collision_key field via an IntegerField.
import asyncio
from sqlalchemy.exc import IntegrityError
import edgy
from edgy import Database, Registry, run_sync
from edgy.contrib.contenttypes import ContentType as _ContentType
database = Database("sqlite:///db.sqlite")
class ContentType(_ContentType):
collision_key = edgy.IntegerField(null=True, unique=True)
class Meta:
abstract = True
models = Registry(database=database, with_content_type=ContentType)
class Apple(edgy.Model):
g = edgy.fields.SmallIntegerField()
class Meta:
registry = models
class Pear(edgy.Model):
g = edgy.fields.SmallIntegerField()
class Meta:
registry = models
async def main():
async with database:
await models.create_all()
await Apple.query.bulk_create(
[{"g": i, "content_type": {"collision_key": i}} for i in range(1, 100, 10)]
)
apples = [
await asyncio.create_task(content_type.get_instance())
async for content_type in models.content_type.query.filter(name="Apple")
]
try:
await Pear.query.bulk_create(
[{"g": i, "content_type": {"collision_key": i}} for i in range(1, 100, 10)]
)
except IntegrityError:
pass
pears = [
await asyncio.create_task(content_type.get_instance())
async for content_type in models.content_type.query.filter(name="Pear")
]
assert len(pears) == 0
run_sync(main())
Or now we allow fruits with the same weight. Let's just remove the uniqueness from the collision_key field.
import asyncio
from sqlalchemy.exc import IntegrityError
import edgy
from edgy import Database, Registry, run_sync
from edgy.contrib.contenttypes import ContentType as _ContentType
database = Database("sqlite:///db.sqlite")
class ContentType(_ContentType):
collision_key = edgy.IntegerField(null=True, unique=False)
class Meta:
abstract = True
models = Registry(database=database, with_content_type=ContentType)
class Apple(edgy.Model):
g = edgy.fields.SmallIntegerField()
class Meta:
registry = models
class Pear(edgy.Model):
g = edgy.fields.SmallIntegerField()
class Meta:
registry = models
async def main():
async with database:
await models.create_all()
await Apple.query.bulk_create(
[{"g": i, "content_type": {"collision_key": i}} for i in range(1, 100, 10)]
)
apples = [
await asyncio.create_task(content_type.get_instance())
async for content_type in models.content_type.query.filter(name="Apple")
]
await Pear.query.bulk_create(
[{"g": i, "content_type": {"collision_key": i}} for i in range(1, 100, 10)]
)
pears = [
await asyncio.create_task(content_type.get_instance())
async for content_type in models.content_type.query.filter(name="Pear")
]
run_sync(main())
Example 2: Snapshotting¶
Sometime you want to know when an object is created (or updated), so you can reduce the search area or mark old data for deletion.
Edgy is able to do this quite easily:
import asyncio
from datetime import datetime, timedelta
from sqlalchemy.exc import IntegrityError
import edgy
from edgy import Database, Registry, run_sync
from edgy.contrib.contenttypes import ContentType as _ContentType
database = Database("sqlite:///db.sqlite")
class ContentType(_ContentType):
# Because of sqlite we need no_constraint for the virtual deletion
no_constraint = True
created = edgy.fields.DateTimeField(auto_now_add=True, read_only=False)
keep_until = edgy.fields.DateTimeField(null=True)
class Meta:
abstract = True
models = Registry(database=database, with_content_type=ContentType)
class Person(edgy.Model):
first_name = edgy.fields.CharField(max_length=100)
last_name = edgy.fields.CharField(max_length=100)
class Meta:
registry = models
unique_together = [("first_name", "last_name")]
class Organisation(edgy.Model):
name = edgy.fields.CharField(max_length=100, unique=True)
class Meta:
registry = models
class Company(edgy.Model):
name = edgy.fields.CharField(max_length=100, unique=True)
class Meta:
registry = models
class Account(edgy.Model):
owner = edgy.fields.ForeignKey("ContentType", on_delete=edgy.CASCADE)
class Meta:
registry = models
class Contract(edgy.Model):
owner = edgy.fields.ForeignKey("ContentType", on_delete=edgy.CASCADE)
account = edgy.fields.ForeignKey("Account", null=True, on_delete=edgy.SET_NULL)
class Meta:
registry = models
async def main():
async with database:
await models.create_all()
person = await Person.query.create(first_name="John", last_name="Doe")
snapshot_datetime = datetime.now()
keep_until = snapshot_datetime + timedelta(days=50)
org = await Organisation.query.create(
name="Edgy org",
)
comp = await Company.query.create(
name="Edgy inc",
)
account_person = await Account.query.create(
owner=person.content_type,
content_type={"created": snapshot_datetime, "keep_until": keep_until},
)
account_org = await Account.query.create(
owner=org.content_type,
content_type={"created": snapshot_datetime, "keep_until": keep_until},
contracts_set=[
{
"owner": org.content_type,
"content_type": {"created": snapshot_datetime, "keep_until": keep_until},
}
],
)
account_comp = await Account.query.create(
owner=comp.content_type,
content_type={"created": snapshot_datetime, "keep_until": keep_until},
contracts_set=[
{
"owner": comp.content_type,
"content_type": {"created": snapshot_datetime},
}
],
)
# delete old data
print(
"deletions:",
await models.content_type.query.filter(keep_until__lte=keep_until).delete(),
)
print("Remaining accounts:", await Account.query.count()) # should be 0
print("Remaining contracts:", await Contract.query.count()) # should be 1
print("Accounts:", await Account.query.get_or_none(id=account_comp.id))
edgy.run_sync(main())
Tricks¶
CASCADE deletion does not work or constraint problems¶
Sometime CASCADE deletion is not possible because of the underlying database technology (see snapshotting example) or constraints doesn't work like expected, e.g. slowdown.
You can switch to the virtual CASCADE deletion handling without a constraint by using no_constraint = True
.
If you want a completely different handling for one Model, you can use the ContentTypeField and overwrite all extras.
Using in libraries¶
ContentType is always available under the name ContentType
if activated and as a content_type
attribute on registry.
If the attribute content_type
on registry is not None, you can be assured ContentType is available.
Opting out¶
Some models may should not be referencable by ContentType.
You can opt out by overwriting content_type
on the model to opt out with any Field.
Use ExcludeField
to remove the field entirely.
Tenancy compatibility¶
ContentType is out of the box tenancy compatible.