Foreign Keys
Foreign keys allow you to define relationships between models, enabling referential integrity in your database. When you define a foreign key, SQLiter ensures that the referenced record exists and automatically handles actions when the referenced record is deleted or updated.
Defining Foreign Keys
To define a foreign key, use the ForeignKey() function when declaring a model field. The foreign key field stores the primary key (pk) of the referenced model:
from typing import Annotated
from sqliter import SqliterDB
from sqliter.model import BaseDBModel, ForeignKey
class Author(BaseDBModel):
name: str
email: str
class Book(BaseDBModel):
title: str
author_id: int = ForeignKey(
Author,
on_delete="CASCADE",
on_update="CASCADE"
)
db = SqliterDB(":memory:")
db.create_table(Author)
db.create_table(Book)
Important
The referenced table (Author) must be created before the table that references it (Book).
Note
The default foreign key action is RESTRICT for both on_delete and on_update. This means that by default, SQLiter will prevent deletion or updates of referenced records if other records reference them. This is the safest behavior and matches SQLite's default. You must explicitly specify on_delete="CASCADE" or on_update="CASCADE" if you want cascading behavior.
Foreign Key Naming Convention
By default, the foreign key column in the database will be named {field_name}_id. In the example above, the field author_id creates a column named author_id in the books table.
You can customize the column name using the db_column parameter:
class Book(BaseDBModel):
title: str
author_id: int = ForeignKey(
Author,
db_column="writer_id",
on_delete="CASCADE"
)
Type Checking
The examples in this documentation show the simplest syntax that works at runtime and with most type checkers:
author_id: int = ForeignKey(
Author,
on_delete="CASCADE",
on_update="CASCADE"
)
author_id: int | None = ForeignKey(
Author, on_delete="SET NULL", null=True, default=None
)
If you use strict type checking with mypy, you can wrap the type and ForeignKey() with Annotated for explicit type metadata:
from typing import Annotated
author_id: Annotated[
int,
ForeignKey(Author, on_delete="CASCADE", on_update="CASCADE")
]
author_id: Annotated[
int | None,
ForeignKey(Author, on_delete="SET NULL", null=True)
] = None
This is optional for foreign keys but required for the unique() constraint (see Models).
Foreign Key Actions
Foreign keys support actions that define what happens when the referenced record is deleted (on_delete) or updated (on_update). The following actions are available:
CASCADE
When the referenced record is deleted, all records that reference it are also deleted. When the referenced record's primary key is updated, the foreign key values are updated to match. You must explicitly specify on_delete="CASCADE" and on_update="CASCADE" to use this behavior:
class Book(BaseDBModel):
title: str
author_id: int = ForeignKey(
Author,
on_delete="CASCADE",
on_update="CASCADE"
)
author = db.insert(Author(name="Jane Austen", email="[email protected]"))
book = db.insert(Book(title="Pride and Prejudice", author_id=author.pk))
# Deleting the author will also delete the book
db.delete(Author, author.pk)
# The book is now deleted too
books = db.select(Book).filter(author_id=author.pk).fetch_all()
assert len(books) == 0
SET NULL
When the referenced record is deleted or updated, the foreign key field is set to NULL. This requires null=True:
class Book(BaseDBModel):
title: str
author_id: int | None = ForeignKey(
Author, on_delete="SET NULL", null=True, default=None
)
Important
You must set null=True when using SET NULL. SQLiter will raise a ValueError if you try to use SET NULL without null=True.
RESTRICT
Prevents deletion or update of the referenced record if other records reference it. This is the default behavior in SQLiter and matches SQLite's default when foreign keys are enabled:
class Book(BaseDBModel):
title: str
author_id: int = ForeignKey(Author, on_delete="RESTRICT")
author = db.insert(Author(name="Jane Austen", email="[email protected]"))
book = db.insert(Book(title="Pride and Prejudice", author_id=author.pk))
# This will raise a ForeignKeyConstraintError
db.delete(Author, author.pk)
NO ACTION
Similar to RESTRICT in SQLite. The deletion or update is prevented if other records reference the record:
class Book(BaseDBModel):
title: str
author_id: int = ForeignKey(Author, on_delete="NO ACTION")
Nullable Foreign Keys
By default, foreign key fields are required (NOT NULL). You can make them optional by setting null=True:
class Book(BaseDBModel):
title: str
author_id: int | None = ForeignKey(
Author, on_delete="SET NULL", null=True, default=None
)
# Insert a book without an author
book = db.insert(Book(title="Anonymous Book", author_id=None))
One-to-One Relationships
To create a one-to-one relationship, use unique=True:
class Book(BaseDBModel):
title: str
author_id: int = ForeignKey(Author, unique=True)
This ensures that each author can be referenced by only one book.
Querying with Foreign Keys
You can filter records using the foreign key column:
# Get all books by a specific author
books = db.select(Book).filter(author_id=author.pk).fetch_all()
# Get all books without an author
orphaned_books = db.select(Book).filter(author_id=None).fetch_all()
Automatic Indexing
SQLiter automatically creates an index on foreign key columns to improve query performance. This is done when you create the table:
db.create_table(Book) # Automatically creates index on author_id
Foreign Key Errors
SQLiter provides specific exceptions for foreign key constraint violations:
ForeignKeyConstraintError: Raised when a foreign key constraint is violated, such as trying to delete a record that is referenced by other records withRESTRICTorNO ACTIONaction.InvalidForeignKeyError: Raised when an invalid foreign key configuration is detected, such as usingSET NULLwithoutnull=True.
Complete Example
Here's a complete example showing foreign key usage with CASCADE deletion:
from sqliter import SqliterDB
from sqliter.model import BaseDBModel, ForeignKey
class Author(BaseDBModel):
name: str
email: str
class Book(BaseDBModel):
title: str
author_id: int = ForeignKey(
Author,
on_delete="CASCADE",
on_update="CASCADE"
)
# Create database and tables
db = SqliterDB(":memory:")
db.create_table(Author)
db.create_table(Book)
# Insert an author
author = db.insert(Author(
name="Jane Austen",
email="[email protected]"
))
# Insert books by this author
book1 = db.insert(Book(
title="Pride and Prejudice",
author_id=author.pk
))
book2 = db.insert(Book(
title="Sense and Sensibility",
author_id=author.pk
))
# Query books by author
jane_books = db.select(Book).filter(author_id=author.pk).fetch_all()
print(f"Jane has {len(jane_books)} books")
# Delete the author (CASCADE will delete the books)
db.delete(Author, author.pk)
# Verify books are deleted
remaining_books = db.select(Book).fetch_all()
assert len(remaining_books) == 0
Future Enhancements
Phase 1 of foreign key support (current implementation) focuses on constraint enforcement and explicit _id field access. Phase 2 will add ORM-style conveniences such as:
- Lazy loading:
book.author.nameinstead ofbook.author_id - Reverse relationships:
author.books.fetch_all() - String references for forward references
See the TODO for more details on planned enhancements.