Skip to content

Contenttypes

Intro

Relational database systems operate using tables that are generally independent, except for foreign keys, which work well in most cases. However, this design has a minor drawback.

Querying and iterating generically across tables and domains can be challenging. This is where ContentTypes come into play. ContentTypes abstract all tables into a single table, providing a powerful solution. By maintaining a single table with backlinks to all other tables, it becomes possible to create generic tables with logic that applies universally.

Typically, uniqueness can only be enforced within individual tables. However, with the ContentType table, it is now possible to enforce uniqueness across multiple tables—this can be achieved efficiently by compressing the data, for example, using 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

Since we allow various types of primary keys, we must inject a unique field into every model to enable backward traversal.

Example: The art of comparing apples with pears

Imagine we need to compare apples and pears based on weight, ensuring only fruits with different weights are considered.

Since weight is a small number, we can simply store 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 that the comparison across all domains is based solely on weight, we can even replace the collision_key field with 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, if we now allow fruits with the same weight, we can simply remove the uniqueness constraint 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

Sometimes, you may need to track when an object is created or updated to narrow the search scope or mark outdated data for deletion.

Edgy makes this process straightforward:

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 Issues or Constraint Problems

Sometimes, CASCADE deletion is not possible due to limitations in the underlying database technology (see the snapshotting example) or unexpected constraint behavior, such as performance slowdowns.

To handle this, you can switch to virtual CASCADE deletion without enforcing a constraint by setting no_constraint = True.

If you need a completely different deletion strategy for a specific model, you can use the ContentTypeField and override all extras.

Using in Libraries

If activated, ContentType is always available under the name ContentType and as a content_type attribute on the registry.

If the content_type attribute on the registry is not None, you can be sure that ContentType is available.

Opting Out

Some models should not be referencable by ContentType.

To opt out, override content_type on the model with any field. Use ExcludeField to remove the field entirely.

Tenancy Compatibility

ContentType is tenancy-compatible out of the box.