Skip to content
Docs

SQL Computed Columns in GraphQL

SQL computed columns let BifrostQL expose table-level virtual fields in GraphQL without changing the database schema. Declare the field in metadata, and it appears in the generated type as a selectable field backed by an expression. The same module surface also supports server-side mutation validation.

Three kinds of computed column exist. Use computed-expr for SQL expressions, computed-plugin for values a .NET provider produces, and file-folder for a storage folder rendered as JSON.

computed-expr table metadata is the supported way to declare a SQL computed column. Each entry is:

fieldName:GraphQlType:expression
dbo.contacts {
computed-expr: fullName:String:UPPER(firstName) || ' ' || lastName
}

The expression is not a SQL string. BifrostQL parses it into a SqlExpr tree, resolves each name against the table (GraphQL field name first, then physical column name), and asks the active dialect to lower the tree through ISqlDialect.LowerExpression. Each dialect spells concatenation, function names, and casts its own way, so one declaration runs on SQL Server, PostgreSQL, MySQL, and SQLite alike.

Every literal in the expression binds as a SQL parameter. There is no path by which expression text reaches the database verbatim, and a name that matches no column fails at model load rather than at query time.

Expression fields are read-only. They are projected into the SELECT list and are not added to mutation inputs.

To build the same expression trees from C# instead of metadata text, see the portable SQL expression builder.

computed-sql metadata takes a raw SQL fragment with {column} placeholders:

dbo.orders {
computed-sql: totalWithTax:Float:({subtotal} + {tax})
}

This kind is deprecated. ComputedColumnKind.Sql and its renderer both carry [Obsolete], and the collector refuses to build a raw-SQL computed column unless model-level raw-sql metadata is switched on — it defaults to off, and a table that declares one without the switch fails model load with an access-denied error.

Raw-SQL fragments carry verbatim text into the generated statement and are not portable across dialects. Migrate each one to computed-expr, which parameterizes every value and validates every name.

Use computed-plugin table metadata for .NET/provider-backed fields, including enrichment from remote APIs.

dbo.orders {
computed-plugin: shippingEstimate:String:shipping-api:depends=Id,destination_zip
}

Register an IComputedColumnProvider whose Name matches the metadata provider name:

public sealed class ShippingEstimateProvider : IComputedColumnProvider
{
public string Name => "shipping-api";
public async ValueTask<object?> ComputeAsync(
ComputedColumnContext context,
CancellationToken cancellationToken = default)
{
var id = context.Row["Id"];
var zip = context.Row["destination_zip"];
// Call a remote service or local dependency here.
return "2 business days";
}
}

Provider fields are computed after the database query returns. If no depends= list is supplied, BifrostQL projects the table primary key columns so the provider has row identity.

Use file-folder table metadata to expose a storage folder as a read-only JSON column. This is useful for CMS, DAM, and other blob-backed content models where the database row owns a folder of files.

dbo.pages {
storage: bucket:/srv/cms;provider:local
file-folder: assets:JSON:local:folder=assets/{Id},depends=Id,recursive=false
}

The emitted assets field returns file/folder entries with name, key, isFolder, size, lastModified, contentType, and url fields. The folder template can reference projected row values with {ColumnName} placeholders.

Built-in providers:

  • local / file-folder-local — lists folders from the local filesystem storage bucket.
  • s3 / file-folder-s3 — lists objects and common prefixes from S3 or S3-compatible storage.

You can configure the folder column inline:

dbo.assets {
file-folder: files:JSON:s3:folder=tenant/{tenant_id}/assets,depends=tenant_id,bucket=my-bucket,region=us-east-1,prefix=prod
}

Or use table/database storage metadata as the default bucket config and keep the folder column focused on the row-specific path.

Schema-derived validation in the editor — A SQLite-backed CRM whose invoices table declares NVARCHAR(12), DECIMAL(7,2) and SMALLINT — SQLite enforces none of it; BifrostQL reads the declarations and refuses invalid values at the field, at the API, and on every protocol front door.

Server-side validation runs by default on every insert and update mutation: any validation metadata you declare is enforced, with no enable flag required.

dbo.contacts.name { required: true }
dbo.contacts.age { min: 18 }
dbo.contacts.email {
pattern: ^[^@]+@[^@]+\.[^@]+$
pattern-message: Email must be valid.
}

Supported built-in rules are required, min, max, minlength, maxlength, step, pattern, pattern-message, and input-type (email/url). Patterns are anchored as a full-string match (like the HTML5 pattern attribute), and a pathological pattern is bounded so it cannot hang a mutation.

Alongside the declared rules, schema-derived validation enforces what the database schema itself declares, so a value the engine would reject fails with a clear per-field message instead of a wrapped database error — on every access method (GraphQL and all protocol adapters), since it runs in the unskippable mutation transformer chain:

  • String lengthsVARCHAR(n)/NVARCHAR(n) (and every character type’s declared length, read from the database schema) enforce as maxlength.
  • Binary lengthsVARBINARY(n)/BINARY(n) byte budgets are enforced for raw bytes and base64 payloads.
  • Date/time parseability and engine ranges — temporal inputs travel as strings; unparseable text is refused, and the dialect’s storable window is enforced (SQL Server datetime before 1753, MySQL TIMESTAMP outside 1970–2038).
  • Integer ranges — values outside the column type’s storable range (int, smallint, bigint, SQL Server tinyint, MySQL unsigned unions) and fractional values on integer columns are refused.
  • Decimal precision — an integer part that overflows DECIMAL(p,s) is refused; excess fractional digits are left to round, as every engine does.

The same server-validation: off switch disables these together with the declared rules.

One shared implementation (SchemaDerivedValueValidator) runs on every server surface — the mutation transformer chain and the server-rendered form validator (BifrostFormValidator) — so a value refused on one surface can never pass another. For clients, _dbSchema advertises a temporal column’s engine window as its min/max when no metadata declares one, so date pickers bound their range and browser-side validation refuses the same values the server would.

To turn validation off for a table or column, set server-validation to an off value (off, false, disabled, none, no, 0):

dbo.imports { server-validation: off } # whole table
dbo.contacts.legacy_blob { server-validation: off } # single column

For custom validation, use validation-plugin with registered IServerValidationProvider implementations:

dbo.contacts { validation-plugin: custom-contact-rules }
public sealed class ContactRules : IServerValidationProvider
{
public string Name => "custom-contact-rules";
public async ValueTask<IReadOnlyList<string>> ValidateAsync(
ServerValidationContext context,
CancellationToken cancellationToken = default)
{
// Return zero or more error messages. Any error aborts the mutation.
// Async lets you call a database or external policy service here.
return Array.Empty<string>();
}
}

Validation runs inside the mutation pipeline, so it applies to top-level and nested (tree-sync) writes alike. The same declarative rules (required, min, pattern, …) are derived once and exposed to generated client forms, keeping browser and server validation in lockstep. For the full hook surface — before-commit veto hooks, custom transformers, and DI registration — see Extending BifrostQL.