Skip to content
Docs

Cross-Cutting Modules and Transformers

BifrostQL’s module system handles cross-cutting concerns through metadata configuration. No custom code required for common patterns like tenant isolation, claim-based filters, soft-delete, and audit columns.

Need to plug in your own logic — a custom filter, a mutation veto, an async validator, a computed column? See Extending BifrostQL (Hooks & Providers) for the programming surface.

The module system has these extension points:

Extension Interface Purpose Behavior
Filter Transformer IFilterTransformer Inject WHERE clauses Applied to every SELECT. Throws to abort queries.
Mutation Transformer IMutationTransformer Transform mutation operations Can change operation type (e.g., DELETE → UPDATE). Runs TransformAsync.
Before-Commit Hook IBeforeCommitMutationHook Veto a mutation inside the transaction Returns errors (or throws) to roll back before commit.
Query Observer IQueryObserver Side effects at lifecycle phases Exceptions are logged but don’t abort queries.
Server Validator IServerValidationProvider Async per-mutation validation Returns error messages; any error aborts the mutation.
Computed Column IComputedColumnProvider Provider-backed virtual fields Computed after the row is read.

The built-in transformers (tenant filter, auto-filter, soft-delete, audit columns, policy, state machine, enum value, server validation) auto-register from metadata — declaring the metadata key is enough; you do not wire them up in code. See Extending BifrostQL to register your own.

The tenant filter transformer injects a WHERE clause on every query to a configured table, using the authenticated user’s tenant ID.

{
"BifrostQL": {
"Metadata": [
"dbo.orders { tenant-filter: tenant_id; }",
"dbo.products { tenant-filter: tenant_id; }",
"dbo.invoices { tenant-filter: tenant_id; }"
]
}
}

Or apply to all tables that have a tenant_id column:

"dbo.*|has(tenant_id) { tenant-filter: tenant_id; }"
  1. BifrostQL reads tenant_id from the authenticated user’s JWT claims via BifrostContext
  2. Every query to a tenant-filtered table gets WHERE tenant_id = @tenantId appended
  3. If the tenant ID is missing from the user context, the query is aborted with an error

There is no opt-out per query. The filter is applied at the SQL level before execution. Queries physically cannot return data from another tenant.

Writes are scoped too. An insert pins the tenant column to the caller’s tenant, and an update or delete only reaches the caller’s rows.

A write also cannot point a foreign key at another tenant’s row. When an insert or update — single-row, upsert, batch, updateWhere, or a nested sync/save tree — sets a database foreign key whose parent table declares tenant-filter, BifrostQL checks inside the write’s transaction that the parent row exists and belongs to the caller’s tenant. It locks that parent row until commit, so its tenant cannot change underneath the write. A parent inserted earlier in the same tree passes.

  • A NULL foreign key references nothing and is not checked.
  • A parent table without tenant-filter (shared lookup data such as countries) is not checked.
  • A composite foreign key is checked on all of its columns together. An update that sets only some columns of a composite foreign key is refused.
  • Name-based and polymorphic relationships are conventions, not constraints, and are not checked.
  • There is no admin bypass, matching the rest of tenant isolation. A missing tenant claim fails closed.

A refused write fails with ACCESS_DENIED and one constant message, whether the parent does not exist or belongs to another tenant, so the answer cannot be used to discover other tenants’ keys:

A referenced row is not available.

Tables with such a foreign key always use the per-row batch path; the bulk set-based fast path cannot run the check.

By default, BifrostQL looks for tenant_id in the user context. Change the claim key with model-level metadata:

":root { tenant-context-key: org_id; }"

Use auto-filter when the filter is not strictly a tenant ID, or when one table needs several claim-to-column mappings.

{
"BifrostQL": {
"Metadata": [
"dbo.orders { auto-filter: organization_id:org_id,region_id:region; }",
":root { auto-filter-bypass-role: admin; }"
]
}
}

Each mapping is column:claim. The transformer reads the claim from the authenticated user context and injects an equality filter for that column. If the claim value is an array, the generated SQL uses an IN filter. The bypass role is read from UserContext["roles"].

Soft delete converts DELETE mutations into UPDATE mutations that set a timestamp, and adds a filter to all SELECT queries to exclude soft-deleted rows.

{
"BifrostQL": {
"Metadata": [
"dbo.orders { soft-delete: deleted_at; soft-delete-by: deleted_by_user_id; delete-type: soft; }"
]
}
}
  • Queries: A WHERE deleted_at IS NULL filter is added to every query against the table
  • DELETE mutations: Transformed into UPDATE ... SET deleted_at = NOW(), deleted_by_user_id = @userId
  • Schema: Soft-deleted tables expose extra query and mutation arguments for administrative access
Argument Side Effect
_includeDeleted: Boolean query Include soft-deleted rows alongside live rows
_onlyDeleted: Boolean query Return only soft-deleted rows (takes precedence) — power a “recycle bin” view
_hardDelete: Boolean mutation Bypass the soft-delete rewrite and issue a real DELETE — opt-in only, see below
# Administrative read — show the recycle bin
{
orders(_onlyDeleted: true) {
data { orderId deletedAt }
}
}

_hardDelete physically removes rows, so it is not part of the schema of a plain soft-delete table. A table exposes the argument only when it opts in by naming the role allowed to use it:

"dbo.orders { soft-delete: deleted_at; soft-delete-hard-role: purge_admin; }"

On an opted-in table the caller must also hold the named role (UserContext["roles"]). Without the opt-in the argument is absent from the SDL, so the hard-delete branch is unreachable from GraphQL; a programmatic mutation intent that still carries the key is denied.

The hard-delete branch deliberately adds no deleted_at IS NULL guard: purging already-soft-deleted rows is its purpose (a soft delete can never reach them). The role opt-in — not the row’s delete state — is the guard.

# Permanently remove a row (requires soft-delete-hard-role opt-in + the role)
mutation {
orders(delete: { orderId: 42 }, _hardDelete: true) { orderId }
}

The standard delete mutation shape is unchanged: orders(delete: { orderId: 42 }) soft-deletes.

Filter transformers run in priority order (lower number = applied first). The built-in priorities:

  • 0-99: Security and tenant isolation
  • 100-199: Data filtering (soft-delete)
  • 200+: Application-level filters

This means tenant filters are always applied inside (closer to the query), and soft-delete filters wrap around them.

The audit module auto-populates columns from the authenticated user context during mutations.

{
"BifrostQL": {
"Metadata": [
"dbo.*.createdOn { populate: created-on; }",
"dbo.*.updatedOn { populate: updated-on; }",
"dbo.*.createdBy { populate: created-by; }",
"dbo.*.updatedBy { populate: updated-by; }"
]
}
}
Value Populated with
created-on Current timestamp (on insert only)
updated-on Current timestamp (on insert and update)
created-by User audit key from context (on insert only)
updated-by User audit key from context (on insert and update)
deleted-on Current timestamp (on soft-delete)
deleted-by User audit key from context (on soft-delete)

Populated columns are optional in the GraphQL input types and server-owned: a write overwrites any client-supplied value for the columns it stamps (created-* on insert, updated-* on insert and update, deleted-* on delete), and an update drops a client-supplied created-* value. There is no update: none key; the model loader rejects it.

Observers receive notifications at four lifecycle phases:

  1. Parsed – after the GraphQL query is parsed into the internal query tree
  2. Transformed – after filter and mutation transformers have run
  3. BeforeExecute – immediately before SQL execution
  4. AfterExecute – after SQL execution completes

Observers are for side effects: logging, metrics, auditing. If an observer throws, the exception is logged and the query continues. Observers do not modify queries.

Hide tables and columns from the GraphQL schema entirely:

"dbo.sys* { visibility: hidden; }"
"dbo.*.__* { visibility: hidden; }"
"dbo.*.password_hash { visibility: hidden; }"
"dbo.*.internal_notes { visibility: hidden; }"

Hidden items are excluded from the generated schema. They don’t appear in queries, mutations, or introspection results.