Constraints and triggers¶
The constraints a model declares in Meta.constraints and the triggers in Meta.triggers — created with
the table by generate_schemas() and by migrations alike, changed and dropped with their declarations.
from hare.ddl import (
CheckConstraint, ExclusionConstraint, ExclusionConstraintUsing, RawSQLTerm, Trigger, TriggerEvent,
TriggerForEach, TriggerTiming, UniqueConstraint,
)
Constraints¶
The two constraint classes of hare.ddl, declared in Meta.constraints. On ClickHouse a
CheckConstraint works, and a UniqueConstraint (as unique=True) refuses the model when it is bound
(ConfigurationError) — ClickHouse keeps nothing unique; see ClickHouse: uniqueness and
relations.
@dataclass(frozen=True)
class UniqueConstraint:
fields: tuple[str, ...]
name: str | None = None
condition: Q | RawSQLTerm | None = None # a Q or raw SQL — a partial unique index (PostgreSQL and SQLite)
deferrable: bool = False # PostgreSQL only; not together with condition
initially_deferred: bool = False # requires deferrable=True
include: tuple[str, ...] = () # non-key INCLUDE columns (PostgreSQL; SQLite creates the index without them)
nulls_distinct: bool | None = None # PostgreSQL 15+: False = NULLS NOT DISTINCT
without_overlaps: bool = False # PostgreSQL 18+: the last field a range rows may not overlap in
@dataclass(frozen=True)
class CheckConstraint:
# A Q over the model's own fields, raw SQL, or the condition hare adds itself: a GenericForeignKeyField's
# ExclusiveArcCondition, an enum field's EnumMembersCondition.
check: Q | RawSQLTerm | ExclusiveArcCondition | EnumMembersCondition
name: str
class Book(Model):
...
class Meta:
constraints = (
CheckConstraint(name="book_valid", check=Q(pages__gt=0) & ~Q(title="")),
UniqueConstraint(fields=("isbn",), name="uq_book_isbn_in_print", condition=Q(in_print=True)),
)
UniqueConstraint¶
fields names one field or more of the model's own columns — an empty tuple, a name that isn't a field
or a ManyToManyField raise ConfigurationError. Without name, one is generated.
nulls_distinct=False makes rows whose fields are equal or NULL collide — (NULL, 1) twice violates
UniqueConstraint(fields=("shelf", "position"), nulls_distinct=False); True states the default. It is
PostgreSQL-only (15+): a model declaring it on another database raises ConfigurationError when it is
bound to the connection (Hare.init(), makemigrations), before a migration no migrate could apply is
written. include stores more columns in the constraint's index, so a query reading only them and the
key is answered from the index alone.
without_overlaps=True makes the last field a range that rows equal in the other fields may not overlap
in; it needs two fields or more and no condition (ConfigurationError). deferrable=True with a
condition raises ConfigurationError too, and on SQLite deferrable=True raises UnSupportedError
when the table is created.
Conditions: Q and RawSQLTerm¶
A condition — of a UniqueConstraint, a CheckConstraint, a PartialIndex or an ExclusionConstraint
— is a Q over the model's own fields or raw SQL wrapped in RawSQLTerm(...), written into the DDL as
it is. Anything else (a plain string, a dict) raises ConfigurationError. A Q is rendered for the
database the DDL runs on, its values inline:
class Book(Model):
...
class Meta:
constraints = (CheckConstraint(name="book_priced_or_free", check=RawSQLTerm("price IS NOT NULL OR is_free")),)
indexes = (PartialIndex(fields=("title",), condition=Q(price__gt=Decimal("1")) | Q(pages__lt=3)),)
Every lookup, F() and an expression over the model's own fields (F("low") + 1, Upper("code"))
works, a relation compared by its own column too (Q(owner__isnull=True), Q(owner__in=[1, 2])); a
lookup reading a related model's column (Q(owner__name="a"), Q(owner__id__gt=1)), an aggregate,
Exists(...) anywhere in the Q and an empty Q() raise ConfigurationError when the model is bound —
before makemigrations writes a migration — and so do a policy's condition and a generated field's
expression reading a related model. Migration files hold the Q itself:
RenameField renames the field inside it, and RemoveField drops a CheckConstraint whose Q reads
only the removed field (one also reading another field raises). Raw SQL is never read: a field it names
is removed and renamed as if it didn't — change the raw SQL in the model, and makemigrations removes and
adds the declaration around the field's change.
On SQLite:
- a text lookup (
contains,startswith,iendswith, ...) is written asGLOB, which matches the same rows case-sensitively: SQLite refuses itsLIKEin such a condition undercase_sensitive_like; a pattern read from another column raisesUnSupportedError; - a
Decimalcompares as a number; - a lookup SQLite only has through hare's own functions (
JSONFieldlookups, regular expressions, ...) raisesUnSupportedError— such a function doesn't exist outside hare's connections (see In constraints and indexes).
ExclusionConstraint (PostgreSQL)¶
ExclusionConstraint is EXCLUDE USING <method> (...): it rejects a row whose combination of values
overlaps an existing one, without a check-then-act query before every write.
class ExclusionConstraintUsing(StrEnum):
GIST = "gist"; SPGIST = "spgist"; BTREE = "btree"; HASH = "hash"
@dataclass(frozen=True)
class ExclusionConstraint:
name: str
expressions: tuple[tuple[str | Expression | RawSQLTerm, str], ...] # (expression, operator) pairs
using: ExclusionConstraintUsing = ExclusionConstraintUsing.GIST
condition: Q | RawSQLTerm | None = None
include: tuple[str, ...] = () # non-key INCLUDE columns of the constraint's index
deferrable: bool = False # checked at commit (or after SET CONSTRAINTS ... DEFERRED)
initially_deferred: bool = False # requires deferrable=True
The usual use is periods that may not overlap — a book can't be lent twice at once:
from hare.dialects.postgresql.fields import DateTimeRangeField
class Loan(Model):
book = fields.ForeignKeyField("models.Book")
during = DateTimeRangeField()
class Meta:
constraints = (
ExclusionConstraint(
name="no_overlapping_loans",
expressions=(("book", "="), ("during", "&&")),
using=ExclusionConstraintUsing.GIST,
),
)
An element of expressions is a field's name (a relation means its key column), an expression of hare
over the model's fields — (Lower("code"), "=") — written in PostgreSQL's SQL and renamed with the
fields it reads, or a RawSQLTerm written as it is.
A deferrable exclusion constraint lets a transaction pass through an overlapping state — moving two loans past each other, say — as long as none overlap when it commits.
GiST has no operator class of its own for a plain scalar column (the book key above, a number, text, a
date, a uuid, ...) — that comes from the btree_gist extension. A GiST ExclusionConstraint naming such
a field needs it, so hare creates it the way it creates citext for a CitextField:
generate_schemas() runs CREATE EXTENSION btree_gist, and the migration autodetector adds a
CreateExtension("btree_gist") — no Meta.extensions entry needed. A RawSQLTerm expression's type
isn't known, so for one over a scalar value add "btree_gist" to Meta.extensions yourself. A HASH
constraint takes exactly one expression with = and no include — a hash index has a single key and no
INCLUDE columns; another declaration raises ConfigurationError.
An expression entry can also be a RawSQLTerm instead of a field name, for a computed expression the
model's own fields don't cover directly:
ExclusionConstraint(
name="no_overlapping_reader_loans",
expressions=((RawSQLTerm("lower(reader_email)"), "="), ("during", "&&")),
)
Warning
ExclusionConstraint.expressions can't reference a column on a joined table — a raw expression is
limited to the model's own table. A rule needing a joined column is enforced in application code
instead (inside a transaction, with row locking).
Triggers¶
ClickHouse has no triggers: a model declaring one raises UnSupportedError before any DDL.
class TriggerEvent(StrEnum): INSERT = "INSERT"; UPDATE = "UPDATE"; DELETE = "DELETE"
class TriggerTiming(StrEnum): BEFORE = "BEFORE"; AFTER = "AFTER"; INSTEAD_OF = "INSTEAD OF" # PostgreSQL, views only
class TriggerForEach(StrEnum): ROW = "ROW"; STATEMENT = "STATEMENT" # SQLite: ROW only
@dataclass(frozen=True)
class Trigger:
name: str
on: str # a TriggerEvent member, or raw text like "INSERT OR UPDATE OF parent_id"
body: RawSQLTerm # the full procedural body
timing: TriggerTiming = TriggerTiming.AFTER
for_each: TriggerForEach = TriggerForEach.ROW
when: Q | RawSQLTerm | None = None # the WHEN condition
language: str | None = None # the dialect's default (PostgreSQL: plpgsql)
deferrable: bool = False # PostgreSQL CONSTRAINT TRIGGER
initially_deferred: bool = False # requires deferrable=True
enabled: bool = True # False: kept but not fired (PostgreSQL)
class Category(Model):
...
class Meta:
triggers = (
Trigger(
name="trg_category_depth",
on=TriggerEvent.INSERT,
timing=TriggerTiming.BEFORE,
body=RawSQLTerm("""
IF NEW.parent_id IS NULL THEN
NEW.depth := 1;
ELSE
SELECT depth + 1 INTO NEW.depth FROM "category" WHERE id = NEW.parent_id;
END IF;
RETURN NEW;
"""),
),
)
On PostgreSQL, both a CREATE FUNCTION ... RETURNS TRIGGER and the CREATE TRIGGER attaching it are
generated together — a trigger has no way to attach without a function to call. On SQLite, body is
embedded directly inside CREATE TRIGGER ... BEGIN ... END, with no separate function object.
A trigger of Meta.triggers fires on the model's table, so INSTEAD_OF — which the database fires on a
view only — raises ConfigurationError when the model is defined; a Meta.managed = False model over a
view may declare one. hare creates nothing of an unmanaged model — neither migrations nor
generate_schemas() create its triggers, so such a trigger is created with the view itself (RunSQL).
body and when¶
body and a raw when are RawSQLTerm(...), as a raw condition of a constraint or an index is; plain
text is refused with ConfigurationError. when can be a Q over the model's own fields instead: it
compares the row the trigger fires for — NEW, or OLD of a trigger on DELETE alone — with its values
written into the DDL, and it follows RenameField/RemoveField like a constraint's condition (a field a
trigger's condition names can't be removed). A Q condition needs a row trigger, and one event row: a
STATEMENT trigger, or a trigger on DELETE together with another event, takes RawSQLTerm(...)
instead.
Trigger(
name="trg_book_stock_check",
on=TriggerEvent.UPDATE,
timing=TriggerTiming.BEFORE,
when=Q(stock__lt=0),
body=RawSQLTerm("RAISE EXCEPTION 'stock can''t go negative'; RETURN NEW;"),
)
inspectdb and drift read a SQLite trigger's WHEN back written with or without parentheses around it
(WHEN OLD.price <> NEW.price, WHEN (OLD.price <> NEW.price)).
Deferrable triggers (PostgreSQL)¶
deferrable=True generates a PostgreSQL CREATE CONSTRAINT TRIGGER instead of a plain CREATE TRIGGER
— its check can be postponed to the end of the transaction (SET CONSTRAINTS ... DEFERRED) rather than
firing right after each row. initially_deferred=True makes that the default for every transaction,
without an explicit SET CONSTRAINTS. Both require timing=TriggerTiming.AFTER and
for_each=TriggerForEach.ROW — a constraint trigger can't be BEFORE or statement-level
(ConfigurationError) — and SQLite raises UnSupportedError for deferrable=True, since it has no such
concept.
A disabled trigger (PostgreSQL)¶
enabled=False creates the trigger and disables it (ALTER TABLE ... DISABLE TRIGGER) — it stays in the
database, firing for nothing; SQLite, which fires every trigger it has, raises UnSupportedError.