Trusted C# Functions And Commands
CSharpDB can call host-registered C# scalar functions from SQL and the embedded expression surfaces that sit on top of the engine. This is the CSharpDB equivalent of an Access-style application function integration: the application owns the C# code, registers it while opening or hosting the database, and users call the function by name in database expressions.
This callback feature is intentionally trusted and in-process. Host-registered callbacks do not store C# source code in the database, sandbox user code, load plugin assemblies from database files, or serialize delegates over HTTP or gRPC. For the newer database-owned Admin Forms C# module workflow, see database-owned Admin Forms C# module workflow.
For an end-to-end app-builder walkthrough that combines Admin Forms, collections, macro actions, reports, trusted callbacks, and callback readiness, see the Fulfillment Ops Admin Automation tutorial.
For Admin Forms save-time business validation, see Trusted Validation Rules.
Trusted Commands
CSharpDB also supports trusted host-registered commands for application automation surfaces. Commands are different from scalar functions:
- Scalar functions return a
DbValueand can be used inside SQL or formulas. - Commands return a
DbCommandResultand are invoked by host-driven events such as Admin Forms lifecycle events, Admin Reports render events, and pipeline run hooks.
Commands are intended for Access-style application automation such as auditing, calling application services, sending notifications, refreshing derived state, coordinating UI workflows, or publishing operational run events. They are trusted in-process callbacks registered by the host application.
using CSharpDB.Admin.Forms.Services;
using CSharpDB.Primitives;
builder.Services.AddCSharpDbAdminForms(commands =>
{
commands.AddAsyncCommand(
"AuditCustomerChange",
new DbCommandOptions(
Description: "Writes an application audit entry.",
Timeout: TimeSpan.FromSeconds(10),
IsLongRunning: true),
static async (context, ct) =>
{
long customerId = context.Arguments["Id"].AsInteger;
string eventName = context.Metadata["event"];
await WriteAuditAsync(customerId, eventName, ct);
return DbCommandResult.Success();
});
});
Command names are case-insensitive identifiers. Duplicate command names are rejected during registration.
Use AddCommand(...) for synchronous or ValueTask-returning callbacks and
AddAsyncCommand(...) for Task<DbCommandResult> callbacks. Every command
receives a CancellationToken; host code should pass it to I/O calls and stop
work when cancellation is requested.
DbCommandOptions.Timeout is optional. When set, CSharpDB cancels the command
token if the callback does not finish in time and reports a timeout through the
same surface-specific failure path as other command errors. IsLongRunning is
metadata for hosts and UI surfaces; it does not move the command out of process
or run it on a separate scheduler.
Every registered command exposes a DbHostCallbackDescriptor through
DbCommandDefinition.Descriptor and DbCommandRegistry.Callbacks. The
descriptor is read-only metadata for policy checks, diagnostics, and Admin
visibility. It does not sandbox the command.
Trusted Validation Rules
Admin Forms can also call host-registered validation rules before save. Rules
are registered in the host app with AddCSharpDbAdminFormValidationRules(...)
and referenced from form or control metadata by name. Missing, denied, timed
out, throwing, or failed validation rules block save and appear in the callback
diagnostics path.
See Trusted Validation Rules for registration examples, field-level and form-level metadata, policy grants, diagnostics behavior, and the runnable sample.
What You Can Register
V1 supports synchronous scalar functions:
public delegate DbValue DbScalarFunctionDelegate(
DbScalarFunctionContext context,
ReadOnlySpan<DbValue> arguments);
A scalar function receives database values and returns one database value. Supported value types are:
| CSharpDB type | Read with | Return with |
|---|---|---|
DbType.Integer |
value.AsInteger |
DbValue.FromInteger(...) |
DbType.Real |
value.AsReal |
DbValue.FromReal(...) |
DbType.Text |
value.AsText |
DbValue.FromText(...) |
DbType.Blob |
value.AsBlob |
DbValue.FromBlob(...) |
DbType.Null |
value.IsNull |
DbValue.Null |
Functions are registered with:
using CSharpDB.Engine;
using CSharpDB.Primitives;
var options = new DatabaseOptions()
.ConfigureFunctions(functions =>
{
functions.AddScalar(
"Slugify",
arity: 1,
options: new DbScalarFunctionOptions(
ReturnType: DbType.Text,
IsDeterministic: true,
NullPropagating: true),
invoke: static (_, args) =>
DbValue.FromText(args[0].AsText.ToLowerInvariant().Replace(' ', '-')));
});
Open the database with those options:
await using var db = await Database.OpenAsync("app.db", options);
For tests or transient data:
await using var db = await Database.OpenInMemoryAsync(options);
Complete Example
using CSharpDB.Engine;
using CSharpDB.Primitives;
static string Slugify(string text)
{
return text.Trim().ToLowerInvariant().Replace(' ', '-');
}
var options = new DatabaseOptions()
.ConfigureFunctions(functions =>
{
functions.AddScalar(
"Slugify",
arity: 1,
options: new DbScalarFunctionOptions(
ReturnType: DbType.Text,
IsDeterministic: true,
NullPropagating: true),
invoke: static (_, args) => DbValue.FromText(Slugify(args[0].AsText)));
functions.AddScalar(
"IsEven",
arity: 1,
options: new DbScalarFunctionOptions(
ReturnType: DbType.Integer,
IsDeterministic: true,
NullPropagating: true),
invoke: static (_, args) =>
DbValue.FromInteger(args[0].AsInteger % 2 == 0 ? 1 : 0));
});
await using var db = await Database.OpenAsync("app.db", options);
await db.ExecuteAsync("""
CREATE TABLE articles (
id INTEGER PRIMARY KEY,
title TEXT NOT NULL,
slug TEXT
);
""");
await db.ExecuteAsync("INSERT INTO articles VALUES (1, 'Hello World', Slugify('Hello World'))");
await db.ExecuteAsync("INSERT INTO articles VALUES (2, 'Second Post', Slugify('Second Post'))");
await using var result = await db.ExecuteAsync("""
SELECT id, Slugify(title)
FROM articles
WHERE IsEven(id) = 1
ORDER BY Slugify(title);
""");
VS Code Host Project Workflow
For a runnable C# host project, open
samples/trusted-csharp-host in VS Code. The sample includes .vscode launch
and task files so a developer can press F5, set breakpoints inside the
registered callbacks, and watch SQL and Admin Forms automation invoke ordinary
C# code.
dotnet run --project samples\trusted-csharp-host\TrustedCSharpHostSample.csproj
The sample demonstrates:
DatabaseOptions.ConfigureFunctions(...)for a trusted scalar function.- SQL calling the function by name.
DbCommandRegistryfor a trusted host command.- An Admin Forms
DbActionSequencethat sets a field and then runs the command.
The VS Code story stays host-owned: VS Code is the editor/debugger for the C# host project, while database metadata stores names and declarative action data only.
End-To-End Developer Handoff
The production workflow is a handoff between an app builder and a host developer:
- The app builder creates database metadata that references a callback by name.
Examples include a formula such as
=Slugify([Name])or a form action step such asRunCommandwith command nameSendOpsDigest. - Admin records only the callback name, arity/kind metadata, action arguments, and reference location. It does not store C# source.
- The callback catalog compares referenced names with the callbacks registered by the current host. Missing names appear as missing callback readiness.
- The app builder copies the generated registration stub from Admin and gives it to the host developer.
- The host developer implements the C# function or command in the host project, registers it during startup, and debugs it in VS Code like normal application code.
- The host is restarted. Admin refreshes callback readiness, and the reference changes from missing to registered/allowed when the name, kind, and arity match.
For Admin itself, host-owned demo callbacks are registered in
src/CSharpDB.Admin/Services/AdminHostCallbacks.cs. A scalar function is
registered with DbFunctionRegistry:
functions.AddScalar(
"Slugify",
arity: 1,
options: new DbScalarFunctionOptions(
ReturnType: DbType.Text,
IsDeterministic: true,
NullPropagating: true),
invoke: static (_, args) => DbValue.FromText(Slugify(args[0].AsText)));
A command callback is registered with DbCommandRegistry:
commands.AddCommand(
"SendOpsDigest",
new DbCommandOptions("Sends a fulfillment operations digest."),
static context =>
{
string source = context.Arguments.TryGetValue("source", out DbValue value)
? value.AsText
: "unknown";
// Call host-owned services here.
return DbCommandResult.Success($"Digest requested by {source}.");
});
That C# code belongs to the host application. The database still stores only the
name SendOpsDigest and any declarative action arguments.
Stored Procedures In The Mix
Stored procedures are different from trusted callbacks. They are database-owned SQL definitions with parameter metadata. They are useful when the logic should stay inside CSharpDB and can be expressed as SQL:
| Need | Prefer |
|---|---|
| Reusable multi-statement table work | Stored procedure |
| Transactional updates plus follow-up result sets | Stored procedure |
| A form button that runs reviewed database logic | RunProcedure |
| External API, email, filesystem, queue, or host service call | Trusted command callback |
| Custom scalar calculation inside SQL expressions | Trusted scalar function |
| UI-only behavior such as filtering or control state | Declarative macro action |
A stored procedure can be executed directly through the client API:
ProcedureExecutionResult result = await client.ExecuteProcedureAsync(
"AllocateOrder",
new Dictionary<string, object?>
{
["orderId"] = 7005,
["allocatedBy"] = "Wave Planner",
["note"] = "Allocated from a reviewed procedure.",
});
Admin's SQL editor also accepts EXEC as an Admin command surface:
EXEC AllocateOrder @orderId = 7005, @allocatedBy = 'Wave Planner';
EXEC RefreshOperationalStats;
EXEC tutorial_OpenOrderSnapshot { "status": "released" };
Use RunProcedure from form metadata only when the rendered host enables
procedure actions. Use RunCommand when the same button needs host-owned C#.
When both are needed, run the procedure first for database work and the command
second for the external side effect.
Registration Rules
Function names are SQL identifiers:
- They must start with a letter or
_. - Remaining characters must be letters, digits, or
_. - Lookup is case-insensitive, so
Slugify,slugify, andSLUGIFYrefer to the same function. - A user function name can only be registered once. V1 does not support overloads by arity.
- Reserved built-ins cannot be overridden. Current reserved names are
TEXT,COUNT,SUM,AVG,MIN, andMAX. aritymust match the number of arguments used by the expression.
Registration failures throw immediately so host applications fail at startup instead of later during a query.
ConfigureFunctions sets the function registry for the returned DatabaseOptions. If you chain multiple option helpers, keep all function registrations in one ConfigureFunctions call or assign a single DbFunctionRegistry to DatabaseOptions.Functions.
Function Options
Each function can include DbScalarFunctionOptions:
new DbScalarFunctionOptions(
ReturnType: DbType.Text,
IsDeterministic: true,
NullPropagating: true,
Description: "Formats a URL slug.",
AdditionalCapabilities:
[
new DbExtensionCapabilityRequest(DbExtensionCapability.Clock)
])
| Option | Meaning |
|---|---|
ReturnType |
Optional metadata describing the expected return type. |
IsDeterministic |
Marks the function as returning the same output for the same inputs. V1 exposes the metadata but does not use it for constant folding or index planning. |
NullPropagating |
If any argument is NULL, CSharpDB returns NULL without invoking the delegate. |
Description |
Optional human-readable text for host tools and Admin visibility. |
AdditionalCapabilities |
Optional capability metadata beyond the implicit ScalarFunctions capability. This is for CSharpDB policy mediation and visibility, not a .NET sandbox. |
Metadata |
Optional host-defined descriptor metadata. |
Without NullPropagating, DbValue.Null is passed to the delegate and the function decides what to do.
functions.AddScalar(
"CoalesceText",
arity: 2,
options: new DbScalarFunctionOptions(DbType.Text),
invoke: static (_, args) =>
args[0].IsNull ? args[1] : args[0]);
Registered scalar functions expose DbScalarFunctionDefinition.Descriptor and
DbFunctionRegistry.Callbacks. The descriptor always uses
DbExtensionRuntimeKind.HostCallback, records the callback kind, name, arity,
return type, deterministic/null behavior, and includes the implicit
ScalarFunctions capability plus any additional capabilities declared in the
options.
Commands expose the same descriptor shape through DbCommandDefinition.
Command descriptors include the implicit Commands capability plus any
additional capabilities declared in DbCommandOptions, along with description,
timeout, and long-running metadata.
Hosts can evaluate descriptor capabilities with DbExtensionPolicyEvaluator:
DbHostCallbackDescriptor descriptor = commandRegistry.Callbacks.Single();
DbExtensionPolicyDecision decision = DbExtensionPolicyEvaluator.Evaluate(
descriptor,
new DbExtensionPolicy(
AllowExtensions: true,
Grants:
[
new DbExtensionCapabilityGrant(
DbExtensionCapability.Commands,
DbExtensionCapabilityGrantStatus.Granted),
new DbExtensionCapabilityGrant(
DbExtensionCapability.ReadDatabase,
DbExtensionCapabilityGrantStatus.Granted),
]),
DbExtensionHostMode.Embedded);
Policy evaluation controls what CSharpDB-mediated APIs should allow. It does not restrict arbitrary in-process .NET calls made by trusted host code.
SQL Usage
Registered scalar functions can be used in non-aggregate SQL expression positions:
SELECT Slugify(title) FROM articles;
SELECT * FROM articles WHERE IsEven(id) = 1;
SELECT * FROM articles ORDER BY Slugify(title);
INSERT INTO articles VALUES (3, 'New Title', Slugify('New Title'));
UPDATE articles SET slug = Slugify(title) WHERE slug IS NULL;
They also work in trigger bodies and SQL procedure bodies because those paths execute through the same SQL expression evaluator:
CREATE TABLE article_audit (article_id INTEGER, slug TEXT);
CREATE TRIGGER articles_ai AFTER INSERT ON articles
BEGIN
INSERT INTO article_audit VALUES (NEW.id, Slugify(NEW.title));
END;
Custom functions stay on the residual expression path in V1:
- No index pushdown is inferred from a custom function.
- No generated-column or expression-index behavior is added.
- No constant folding or cost assumptions are made from custom function metadata.
That keeps existing query and storage paths unchanged unless a query actually calls a registered function.
Direct Client Usage
Direct clients pass functions through DirectDatabaseOptions:
using CSharpDB.Client;
using CSharpDB.Engine;
using CSharpDB.Primitives;
await using var client = CSharpDbClient.Create(new CSharpDbClientOptions
{
DataSource = "app.db",
DirectDatabaseOptions = new DatabaseOptions()
.ConfigureFunctions(functions =>
{
functions.AddScalar(
"AddOne",
1,
new DbScalarFunctionOptions(DbType.Integer, IsDeterministic: true, NullPropagating: true),
static (_, args) => DbValue.FromInteger(args[0].AsInteger + 1));
}),
});
await client.ExecuteSqlAsync("CREATE TABLE numbers (value INTEGER);");
await client.ExecuteSqlAsync("INSERT INTO numbers VALUES (41);");
var result = await client.ExecuteSqlAsync("SELECT AddOne(value) FROM numbers;");
DirectDatabaseOptions is only valid for direct transport. It is rejected for HTTP and gRPC clients because delegates cannot be serialized to another process.
Remote Host Usage
HTTP and gRPC clients cannot send C# delegates. Remote SQL can call a custom function only when that function is registered inside the host process that owns the database.
The practical rule is:
- Embedded or direct client: register functions in
DatabaseOptionsorDirectDatabaseOptions. - Remote client: register functions where the daemon, API host, or application server opens the database.
- Pipeline packages, report definitions, form definitions, procedures, and SQL text store function names and expressions only. They do not store C# function bodies.
- Admin Forms, Admin Reports, and pipeline packages also store generated
automationmetadata that lists required trusted commands and scalar functions by name, surface, and location. This is an import/export contract for hosts; it is not executable code.
Admin Forms
Admin Forms computed formulas can call registered scalar functions when the formula evaluator receives a DbFunctionRegistry.
using CSharpDB.Admin.Forms.Evaluation;
using CSharpDB.Primitives;
var functions = DbFunctionRegistry.Create(builder =>
{
builder.AddScalar(
"Tax",
1,
new DbScalarFunctionOptions(DbType.Real, IsDeterministic: true, NullPropagating: true),
static (_, args) => DbValue.FromReal(args[0].AsReal * 0.0825));
});
double? tax = FormulaEvaluator.Evaluate(
"=Tax(Subtotal)",
fieldResolver: name => name == "Subtotal" ? 100.00 : null,
functions: functions);
Forms formulas are numeric formulas. A custom function used from FormulaEvaluator.Evaluate should return INTEGER or REAL; other return types evaluate to null in that surface. Existing aggregate formulas such as =SUM(OrderItems.LineTotal) remain built-in form behavior and are not replaced by custom scalar functions.
Admin Forms can also bind lifecycle events to trusted commands. Form definitions store event names and command names only; the C# command bodies stay registered in the host process.
var form = existingForm with
{
EventBindings =
[
new FormEventBinding(FormEventKind.OnOpen, "AuditFormOpen"),
new FormEventBinding(FormEventKind.BeforeInsert, "ValidateCustomerCreate"),
new FormEventBinding(FormEventKind.AfterUpdate, "AuditCustomerChange"),
],
};
Supported form-level events in this slice are:
| Event | When it runs |
|---|---|
OnOpen |
After the form definition and source table are resolved, before records load. |
OnLoad |
After the initial record page loads. |
BeforeInsert |
Before a new record is inserted. Returning DbCommandResult.Failure(...) cancels the insert. |
AfterInsert |
After a new record is inserted. |
BeforeUpdate |
Before an existing record is updated. Returning failure cancels the update. |
AfterUpdate |
After an existing record is updated. |
BeforeDelete |
Before the current record is deleted. Returning failure cancels the delete. |
AfterDelete |
After the current record is deleted. |
Command context arguments include the current record fields converted to DbValue. Static arguments configured on the event binding override same-named record fields. Metadata includes surface, formId, formName, tableName, and event.
The Admin Forms designer preserves form event bindings and exposes them in the property inspector when no control is selected. If the host has registered trusted commands, the designer shows those command names; otherwise it stores the command name typed by the designer. The same editor can attach a visual action sequence to the event.
Admin Forms also include control-level trusted command events. Form controls store event names, command names, and optional JSON arguments in the form definition. At runtime, the renderer invokes the registered host command with the current record fields plus event-specific arguments.
var textBox = existingTextBox with
{
EventBindings =
[
new ControlEventBinding(
ControlEventKind.OnChange,
"NormalizeCustomerName",
new Dictionary<string, object?> { ["source"] = "name-textbox" }),
new ControlEventBinding(ControlEventKind.OnLostFocus, "ValidateCustomerName"),
],
};
Supported control events in this slice are:
| Event | When it runs |
|---|---|
OnClick |
When a label or command button is clicked. |
OnChange |
After an input, checkbox, radio, select, lookup, or textarea updates its bound field. |
OnGotFocus |
When an interactive control receives focus. |
OnLostFocus |
When an interactive control loses focus. |
Control event metadata includes the Forms metadata plus event, controlId, controlType, and fieldName for bound controls. Arguments include current record fields and event details such as fieldName, value, and oldValue for field changes. Static arguments configured on the event binding override same-named runtime arguments.
The Admin Forms designer exposes selected-control event bindings in the property inspector. If the host has registered trusted commands, the designer shows those command names; otherwise it stores the command name typed by the designer. Selected-control events use the same visual action-sequence editor as form lifecycle events.
Admin Forms also include a command button control. Command buttons store a display label, a command name, and optional JSON arguments in the form definition. At runtime, clicking the button invokes the registered host command with the current record fields plus the configured arguments. Command buttons can also use ControlEventKind.OnClick bindings, which allows a button to be driven entirely by the shared control-event model.
var button = new ControlDefinition(
"btn-ship",
"commandButton",
new Rect(24, 320, 160, 34),
Binding: null,
Props: new PropertyBag(new Dictionary<string, object?>
{
["text"] = "Ship Order",
["commandName"] = "ShipOrder",
["commandArguments"] = new Dictionary<string, object?>
{
["source"] = "form-button",
},
}),
ValidationOverride: null);
Command button direct-command metadata includes the same form metadata as lifecycle events, plus event = "Click", controlId, and controlType.
Declarative Admin Forms Action Sequences
Admin Forms event bindings can also store small declarative action sequences. This is the first Access-style macro layer for CSharpDB Forms: the form stores action metadata, while any executable C# still lives in host-registered trusted commands.
using CSharpDB.Admin.Forms.Models;
using CSharpDB.Primitives;
var shipButton = existingButton with
{
EventBindings =
[
new ControlEventBinding(
ControlEventKind.OnClick,
CommandName: string.Empty,
ActionSequence: new DbActionSequence(
[
new DbActionStep(
DbActionKind.SetFieldValue,
Target: "Status",
Value: "Shipped"),
new DbActionStep(
DbActionKind.RunCommand,
CommandName: "AuditOrderStatus",
Condition: "Status = 'Shipped'",
Arguments: new Dictionary<string, object?>
{
["source"] = "ship-button",
}),
new DbActionStep(
DbActionKind.ShowMessage,
Message: "Order marked as shipped."),
],
Name: "ShipButtonActions")),
],
};
The action set is intentionally form-focused:
| Action | Behavior |
|---|---|
RunCommand |
Invokes a host-registered trusted command by name. |
RunActionSequence |
Invokes a reusable named form action sequence stored on the form definition. |
SetFieldValue |
Updates a target field in the current mutable form record. |
ShowMessage |
Sends a message when the current Forms surface provides a command/message callback. |
Stop |
Ends the current sequence successfully. |
NewRecord |
Starts a new record in the rendered form. |
SaveRecord |
Saves the current rendered record through the normal form save path. |
DeleteRecord |
Deletes the current persisted rendered record through the normal form delete path. |
RefreshRecords |
Reloads the current record/page while preserving the current primary key when possible. |
PreviousRecord |
Moves the rendered form to the previous record. |
NextRecord |
Moves the rendered form to the next record. |
GoToRecord |
Navigates to a primary-key value from Value, Arguments["value"], Arguments["recordId"], Arguments["primaryKey"], or the field named by Target. |
OpenForm |
Opens another saved form through the rendered form host. |
CloseForm |
Closes the current or named rendered form surface. |
ApplyFilter |
Applies a form or data-grid filter. |
ClearFilter |
Clears a form or data-grid filter. |
RunSql |
Executes SQL through the rendered host when SQL actions are enabled by policy. |
RunProcedure |
Executes a saved procedure through the rendered host when procedure actions are enabled by policy. |
SetControlProperty |
Overrides a rendered control property such as visible, enabled, readOnly, text, placeholder, or bound value. |
SetControlVisibility |
Short form for setting a rendered control's visible property. |
SetControlEnabled |
Short form for setting a rendered control's enabled property. |
SetControlReadOnly |
Short form for setting a rendered control's readOnly property. |
Reusable action sequences are stored once on the form and invoked by name from form events, control events, or command buttons:
var form = existingForm with
{
ActionSequences =
[
new DbActionSequence(
[
new DbActionStep(DbActionKind.SetFieldValue, Target: "Status", Value: "Ready"),
new DbActionStep(DbActionKind.RunCommand, CommandName: "AuditReady"),
],
Name: "PrepareReadyStatus"),
],
EventBindings =
[
new FormEventBinding(
FormEventKind.BeforeUpdate,
string.Empty,
ActionSequence: new DbActionSequence(
[
new DbActionStep(
DbActionKind.RunActionSequence,
SequenceName: "PrepareReadyStatus",
Arguments: new Dictionary<string, object?> { ["source"] = "before-update" }),
])),
],
};
RunActionSequence arguments become runtime arguments for the nested sequence,
so nested RunCommand steps receive current record fields, binding arguments,
caller-supplied sequence arguments, and their own step arguments. Recursive
sequence loops fail with a nesting-limit error instead of running forever.
Action sequences can be attached to form lifecycle bindings or selected-control bindings. A binding can contain only a command, only an action sequence, or a command followed by an action sequence:
var form = existingForm with
{
EventBindings =
[
new FormEventBinding(
FormEventKind.BeforeInsert,
"ValidateOrder",
ActionSequence: new DbActionSequence(
[
new DbActionStep(
DbActionKind.SetFieldValue,
Target: "Status",
Value: "Draft"),
])),
],
};
The Admin Forms property inspector exposes action sequences with a visual
editor on form-level and selected-control event bindings. Designers can add a
sequence, name it, add command, reusable-sequence, field, message, stop,
rendered record, form, filter, SQL/procedure, and control-property steps,
reorder or remove steps, choose registered commands or reusable sequences when
available, and set per-step conditions and
StopOnFailure. The form-level property inspector also includes a reusable
action-sequence library editor. JSON editing remains only for optional binding,
RunCommand, or RunActionSequence argument payloads.
For RunCommand, command arguments are built from current record fields,
binding arguments, runtime event arguments, and step arguments, with later
sources overriding earlier ones. Command metadata includes the Forms metadata
plus actionKind, actionStep, optional actionSequence, and optional
actionCondition.
Every action step can include a Condition. Empty conditions run the step.
False conditions skip only that step. Malformed conditions fail through the
normal step failure path, so StopOnFailure = false can allow a later step to
continue.
Supported condition syntax is intentionally small:
| Syntax | Example |
|---|---|
| Truthy value | IsActive |
| Equality | Status = 'Ready' or [Status] == "Ready" |
| Inequality | Status <> 'Closed' or Status != 'Closed' |
| Numeric comparison | Amount > 0, Quantity <= 10 |
| Null comparison | ClosedAt = null |
Condition values are resolved from current record fields, binding arguments,
runtime event arguments, and step arguments using the same later-wins order as
command arguments. A leading = is accepted for macro-style conditions, for
example =Status = 'Ready'.
When forms are saved through DbFormRepository or exported through
FormAutomationMetadata.NormalizeForExport(...), the definition's automation
metadata is regenerated from form events, command buttons, selected-control
events, reusable action sequences, action-sequence RunCommand steps, and
computed-control formulas. Older form JSON without automation metadata is
backfilled when it is loaded.
SetFieldValue can update mutable records in form lifecycle events such as
BeforeInsert and BeforeUpdate, and it can update the current rendered record
from control events or command-button clicks.
Built-in record, form navigation, filter, SQL/procedure, and control-property
actions require a rendered Admin Forms data-entry runtime. They are intended for
command buttons and selected-control events. Headless form lifecycle dispatch
can still run SetFieldValue, ShowMessage, Stop, and RunCommand, but it
reports a failure if a sequence asks for actions that need the rendered form
instance.
Action sequences do not include loops, a RunCode macro action,
database-owned plugins, or remote delegate serialization. Database-owned C#
form modules are handled as trusted event handlers through the separate code
module runtime. Rendered Admin form runtimes support direct SQL and procedure
actions only when the host explicitly enables those capabilities.
Admin Reports
Admin Reports preview rendering accepts the same registry through DefaultReportPreviewService:
using CSharpDB.Admin.Reports.Services;
using CSharpDB.Primitives;
var previewService = new DefaultReportPreviewService(
dbClient,
sourceProvider,
functions);
Numeric calculated expressions can call numeric-returning functions:
=Tax([Subtotal])
Calculated text can use a scalar function as the whole expression, including text-returning functions:
=FormatInvoiceLabel([InvoiceNumber], [CustomerName])
Report aggregate formulas such as =SUM([Subtotal]) remain built-in report behavior.
Admin Reports can also bind preview-render lifecycle events to trusted commands. Report definitions store event names, command names, and optional static arguments only; the C# command bodies stay registered by the host process.
using CSharpDB.Admin.Reports.Models;
var report = existingReport with
{
EventBindings =
[
new ReportEventBinding(ReportEventKind.OnOpen, "AuditReportOpen"),
new ReportEventBinding(ReportEventKind.BeforeRender, "PrepareReportContext"),
new ReportEventBinding(ReportEventKind.AfterRender, "PublishReportRendered"),
],
};
Supported report events are:
| Event | When it runs |
|---|---|
OnOpen |
After the report source is resolved, before preview rows are loaded. |
BeforeRender |
After preview rows are loaded and capped, before pagination and calculated text rendering. |
AfterRender |
After preview pages are produced, before the preview result is returned. |
Command context arguments include render metrics such as rowCount, loadedRowCount, rowTruncated, pageCount, isTruncated, and hasSchemaDrift depending on the event. Static arguments configured on the binding override same-named runtime arguments. Metadata includes surface = AdminReports, reportId, reportName, sourceKind, sourceName, and event.
When reports are saved through DbReportRepository or exported through
ReportAutomationMetadata.NormalizeForExport(...), the definition's
automation metadata is regenerated from report event bindings and calculated
text expressions. Older report JSON without automation metadata is backfilled
when it is loaded.
Register report commands through the reports service registration overload:
using CSharpDB.Admin.Reports.Services;
using CSharpDB.Primitives;
builder.Services.AddCSharpDbAdminReports(commands =>
{
commands.AddCommand("PublishReportRendered", static context =>
{
string reportName = context.Metadata["reportName"];
long pageCount = context.Arguments["pageCount"].AsInteger;
PublishReportMetric(reportName, pageCount);
return DbCommandResult.Success();
});
});
Pipelines
Pipelines can call registered scalar functions in filter expressions and derived-column expressions when the runner or component factory is constructed with a registry.
using CSharpDB.Client.Pipelines;
using CSharpDB.Pipelines.Models;
using CSharpDB.Primitives;
var functions = DbFunctionRegistry.Create(builder =>
{
builder.AddScalar(
"NormalizeStatus",
1,
new DbScalarFunctionOptions(DbType.Text, IsDeterministic: true, NullPropagating: true),
static (_, args) => DbValue.FromText(args[0].AsText.Trim().ToLowerInvariant()));
});
var runner = new CSharpDbPipelineRunner(client, functions);
var package = new PipelinePackageDefinition
{
Name = "active-customers",
Version = "1.0.0",
Source = new PipelineSourceDefinition
{
Kind = PipelineSourceKind.CsvFile,
Path = "customers.csv",
HasHeaderRow = true,
},
Transforms =
[
new PipelineTransformDefinition
{
Kind = PipelineTransformKind.Filter,
FilterExpression = "NormalizeStatus(status) == 'active'",
},
new PipelineTransformDefinition
{
Kind = PipelineTransformKind.Derive,
DerivedColumns =
[
new PipelineDerivedColumn
{
Name = "status_key",
Expression = "NormalizeStatus(status)",
},
],
},
],
Destination = new PipelineDestinationDefinition
{
Kind = PipelineDestinationKind.JsonFile,
Path = "active-customers.json",
},
};
await runner.RunPackageAsync(package);
Pipeline package JSON stores expressions such as NormalizeStatus(status) plus generated automation metadata listing the required scalar function names. The C# delegate must be registered by the process that runs the package.
Pipelines can also invoke trusted commands from run hooks. Hook definitions are serialized with the package, but they store only the hook event, command name, optional static arguments, and generated automation metadata:
var commands = DbCommandRegistry.Create(builder =>
{
builder.AddCommand("NotifyPipeline", static context =>
{
string pipelineName = context.Metadata["pipelineName"];
string status = context.Arguments["status"].AsText;
long rowsWritten = context.Arguments["rowsWritten"].AsInteger;
NotifyOps(pipelineName, status, rowsWritten);
return DbCommandResult.Success();
});
});
var runner = new CSharpDbPipelineRunner(client, functions, commands);
var package = new PipelinePackageDefinition
{
Name = "active-customers",
Version = "1.0.0",
Source = new PipelineSourceDefinition
{
Kind = PipelineSourceKind.CsvFile,
Path = "customers.csv",
},
Destination = new PipelineDestinationDefinition
{
Kind = PipelineDestinationKind.JsonFile,
Path = "active-customers.json",
},
Hooks =
[
new PipelineCommandHookDefinition
{
Event = PipelineCommandHookEvent.OnRunSucceeded,
CommandName = "NotifyPipeline",
Arguments = new Dictionary<string, object?>
{
["channel"] = "ops",
},
},
],
};
Supported pipeline hook events are:
| Event | When it runs |
|---|---|
OnRunStarted |
After package validation and run logging, before components are created. |
OnBatchCompleted |
After each source batch is transformed/written, metrics and checkpoints are updated, and reject limits pass. |
OnRunSucceeded |
After destination completion and before the successful run is logged as completed. |
OnRunFailed |
When the orchestrator is about to return a failed PipelineRunResult. |
Hook arguments include runId, pipelineName, pipelineVersion, mode, event, status, rowsRead, rowsWritten, rowsRejected, and batchesCompleted. Batch hooks also include batchNumber, startingRowNumber, and batchRowCount. Failure hooks include errorSummary. Metadata includes surface = Pipelines, pipelineName, pipelineVersion, runId, mode, and event.
PipelinePackageSerializer refreshes the automation manifest when packages are serialized, saved, deserialized, or loaded from disk. PipelinePackageValidator accepts older packages without a manifest, but if a manifest is present and no longer matches the package expressions/hooks, validation reports stale automation metadata so the package can be re-exported.
Validate mode does not invoke command hooks, so package validation stays side-effect free. Missing command registration or a failing hook with StopOnFailure = true fails the run normally. For OnRunFailed, hook failures are appended to the failed run's error summary instead of recursively dispatching more failure hooks.
Error Handling
Missing SQL functions fail with the existing unknown scalar function error. Function exceptions are wrapped with the function name and the surrounding statement follows normal rollback behavior.
functions.AddScalar(
"RequirePositive",
1,
new DbScalarFunctionOptions(DbType.Integer, NullPropagating: true),
static (context, args) =>
{
long value = args[0].AsInteger;
if (value <= 0)
throw new ArgumentOutOfRangeException(context.FunctionName, "Value must be positive.");
return DbValue.FromInteger(value);
});
For SQL write statements, a failing function aborts the statement. If the statement is inside a transaction, normal transaction rollback rules apply.
Admin Forms formulas intentionally return null for invalid formulas, unsupported function return types, missing functions, division by zero, or exceptions. Pipeline functions throw runtime errors unless the pipeline error mode handles the affected row.
Trusted command failures are surface-specific. Form before-events can cancel writes, report event failures fail preview rendering, and pipeline hook failures produce a failed PipelineRunResult unless the binding sets StopOnFailure = false. Timed-out commands are reported as command failures; caller-requested cancellation still propagates as cancellation instead of being converted to a failure message.
Forms action-sequence failures follow the same binding-level StopOnFailure
rule. Step-level StopOnFailure = false lets a later step continue after that
step fails; otherwise the sequence reports the failure to the surrounding form
or control event.
Performance Guidance
Custom functions run only when an expression calls them. Queries and writes that do not use custom functions stay on the existing paths.
For low overhead:
- Prefer
NullPropagating = truewhen a function naturally returns null for null input. - Avoid database calls, blocking I/O, sleeps, and long network calls inside delegates.
- For command callbacks that call application services, prefer
AddAsyncCommand(...), honor the provided cancellation token, and set a timeout that matches the user-facing workflow. - Keep delegates thread-safe. A function may be called by concurrent queries in the same host process.
- Capture immutable services or thread-safe services in closures when application integration is needed.
- Use
IsDeterministic = truefor accurate metadata, but do not rely on V1 to optimize from it.
Current Limitations
V1 does not support:
- Aggregate UDFs.
- Table-valued UDFs.
- Database-owned C# modules beyond the local Admin Forms event-handler MVP, such as report modules, procedure modules, or remote/daemon execution.
- Sandboxed execution.
- Async scalar delegates.
- Passing a database handle into the function context.
- Sending delegates over HTTP, gRPC, or pipeline package files.
- Optimizer pushdown, expression indexes, generated columns, or constant folding based on custom function metadata.
- Additional Access-style control events such as double-click, key, mouse, timer, and dirty/current events.
- Richer macro/action scripts with loops, reusable UI rule presets, additional event surfaces, or
RunCodeaction invocation.