Skip to content

How it compiles to SQL

You don’t need this to use Warrant, but it explains why the semantics are what they are — and why the three questions can never disagree.

For each requested ability, the compiler assembles one predicate from all the rules that mention it (or *):

predicate(ability) =
( OR of each `can` rule's if-expression )
AND ( AND of NOT(each `cannot` rule's if-expression) )

with these hard edges:

  • An unconditional cannotAND NOT(true)1 = 0: never, on any row.
  • No can rule for the ability → 1 = 0: denied by default.
  • An unconditional can → an always-true 1 = 1 term: every row.

See Deny-overrides for how this plays out in practice.

Each condition leaf is wrapped as an EXISTS subquery. That makes it a strict boolean:

  • A condition that touches a NULL column yields false, not SQL’s “unknown.”
  • Negation via NOT EXISTS is exact.

This is why not / cannot behave predictably — no three-valued-logic surprises leak into your authorization results. Boolean structure (and / or / not) becomes nested WHERE groups, with negation pushed to the leaves via De Morgan.

In a no-target check (a capability check, or getUserAbilities() with no target), a targeted condition has no row to correlate against, so the compiler forces it to 1 = 0 (false) — and, under negation, 1 = 1 (true). Global conditions still evaluate normally. This is the same rule that makes an absent optional @context key soft-false its condition.

The examples below use a documents schema whose conditions emit this SQL (the body of each condition method):

Condition SQL it adds
is_self (targeted) documents.user_id = ?
manages_team (targeted) documents.team_id in (?, ?)
is_locked (targeted) documents.locked = 1
is_admin (global) ? = ? — the string 'admin' vs. the user’s role

The SQL below is real compiler output, with redundant nested parentheses trimmed and ? placeholders annotated with their bound values.

An unconditional grantthey can view — is an always-true term:

select * from documents where (1 = 1)

A single targeted conditionif is_self they can view — becomes one EXISTS. The condition’s documents.user_id correlates to the outer row, so the subquery is true exactly for the rows the user owns:

select * from documents
where exists (
select 1 from (select 1) as warrant_exists
where documents.user_id = ? -- ? → the current user's id
)

A global conditionif is_admin they can delete — compiles the same way, but its EXISTS doesn’t reference the row (it’s true or false for the whole request):

select * from documents
where exists (
select 1 from (select 1) as warrant_exists
where ? = ? -- 'admin' = the user's role
)

No can rule for the ability, or an unconditional cannot, is a hard 1 = 0 — even alongside a grant (they can view + they cannot view), because deny always wins:

select * from documents where (1 = 0)

The full opener — three rules for update:

if is_self or manages_team they can update
if is_locked and not is_admin they cannot update
if is_admin they can *

compiles to one predicate: an OR of every can source, ANDed with the negated cannot:

select * from documents where
(
-- grant side: the two-part first rule, OR the wildcard `is_admin` rule
exists (select 1 from (select 1) as warrant_exists where documents.user_id = ?) -- is_self
or exists (select 1 from (select 1) as warrant_exists where documents.team_id in (?, ?)) -- manages_team
or exists (select 1 from (select 1) as warrant_exists where ? = ?) -- is_admin (from `they can *`)
)
and (
-- deny side: NOT(is_locked and not is_admin), De-Morgan'd onto the leaves
not exists (select 1 from (select 1) as warrant_exists where documents.locked = 1) -- not is_locked
or exists (select 1 from (select 1) as warrant_exists where ? = ?) -- or is_admin
)
-- bindings: [user_id, team_a, team_b, 'admin', role, 'admin', role]

The cannot update guarded by is_locked and not is_admin becomes NOT(is_locked AND NOT is_admin), which De Morgan turns into (NOT is_locked OR is_admin) — negation always lands on the EXISTS leaves, never on a group, so it stays a strict two-valued boolean.

Several abilities combine per the match mode. hasAbility(['view', 'update'], matchMode: ALL) ANDs the two per-ability predicates (ANY would OR them):

select * from documents where
exists (select 1 from (select 1) as warrant_exists where documents.user_id = ?) -- view: is_self
and exists (select 1 from (select 1) as warrant_exists where ? = ?) -- update: is_admin

Per-row abilities (selectAbilities) run the same per-ability predicates as a correlated subquery per row — one SELECT ? as ability WHERE <predicate> UNION ALL branch per requested ability, aggregated into a JSON array:

select *, (
select coalesce(json_group_array(ability), json_array())
from (
select ? as ability where exists (select 1 from (select 1) as warrant_exists where documents.user_id = ?) -- view
union all
select ? as ability where exists (select 1 from (select 1) as warrant_exists where ? = ?) -- delete
) as available_abilities
) as abilities
from documents

Each row’s abilities column ends up holding just the abilities whose predicate held for that row — e.g. ["view"] for a document the user owns but can’t delete. (The JSON aggregate differs by driver — see below.)

  • “Which rows?” — the predicates become your query’s WHERE (hasAbility).
  • “What can they do to each row?” — the predicates run as correlated subqueries producing a JSON column (selectAbilities).
  • “Can they?” — the predicate runs as a scoped EXISTS (userHasAbilities).

Because everything is one compiler, the three can never disagree.

The selectAbilities JSON column uses each database’s native JSON aggregate:

Driver Aggregate
PostgreSQL coalesce(json_agg(...), '[]'::json)
MySQL / MariaDB coalesce(json_arrayagg(...), json_array())
SQLite coalesce(json_group_array(...), json_array())

Any other driver throws at query-build time. The subquery builds one UNION ALL branch per ability, which is why narrowing with onlyAbilities is a real cost saving on wide lists.

Compilation validates every ability and condition name against the schema; an unknown name is a hard error, so a typo in a stored rule fails loudly rather than silently granting or denying. Context-key references are validated the same way. See Errors & exceptions.