Portal Community

Contents

  1. What Are Canned Expressions?
  2. Syntax
  3. Resolution Order
  4. Database Schema
  5. ICannedExpressionStore
  6. How the @ Directive Works
  7. Common Patterns
  8. Managing via API

What Are Canned Expressions?

A canned expression is a named alias stored in the database that maps to a full expression string. Instead of repeating {@ $ctx.tenant.defaultCurrency } in every workflow node, you define it once as @company.currency and reference that alias everywhere.

Benefits:

Syntax

Canned expressions use the AtSign parser (AtSignWildcardParser). The syntax is simply @name or @category.name:

Simple alias
@company.currency
In a template string (mixed with other directives)
Total: {@ $var.subtotal } @company.currency
Alias that itself contains an expression (nested)
@hr.approvalEmail
Stored as: {@ $ctx.tenant.hrDepartmentEmail @lowercase } → resolved at runtime
The @ prefix is the alias directive When the AtSign parser detects @name, it routes to CannedExpressionDirectiveService (directive name: "@"). The name is stripped of the @ and used as the lookup key.

Resolution Order

When resolving alias @company.currency for tenant 5, app 12:

  1. Look up (TenantId=5, AppId=12, Name="company.currency") — app-specific
  2. If not found: (TenantId=5, AppId=NULL, Name="company.currency") — tenant-wide
  3. If not found: Failure(CannedExpressionNotFound)

This hierarchy allows platform-wide defaults to be overridden per-tenant, and tenant defaults to be overridden per-application:

// Tenant 5 (default): @company.currency = "{@ $ctx.env.DEFAULT_CURRENCY }" = "EUR"
// Tenant 5, App 7 (UK subsidiary): @company.currency = "GBP"
// Tenant 5, App 3 (US entity):     @company.currency = "USD"

// For AppId=7 → "GBP"  (app-specific wins)
// For AppId=3 → "USD"  (app-specific wins)
// For AppId=9 → "EUR"  (falls back to tenant-wide)

Database Schema

CREATE TABLE Expression_CannedExpressions (
    ExpressionID     INT IDENTITY(1,1)   CONSTRAINT PK_Expression_CannedExpressions PRIMARY KEY,
    TenantID         INT          NOT NULL,
    AppID            INT          NULL,         -- NULL = tenant-wide scope
    Name             NVARCHAR(200) NOT NULL,   -- e.g., "company.currency"
    Expression       NVARCHAR(MAX) NOT NULL,    -- the stored expression string
    Description      NVARCHAR(500) NULL,
    Tags             NVARCHAR(MAX) NULL,         -- JSON array: ["billing","finance"]
    IsActive         BIT          NOT NULL CONSTRAINT DF_Expression_CannedExpressions_IsActive DEFAULT (1),

    -- Standard audit columns
    CreatedOn        DATETIME     NOT NULL,
    CreatedBy        INT          NOT NULL,
    LastModifiedOn   DATETIME     NOT NULL,
    LastModifiedBy   INT          NOT NULL,
    Deleted          BIT          NOT NULL CONSTRAINT DF_Expression_CannedExpressions_Deleted DEFAULT (0),
    Archived         BIT          NOT NULL CONSTRAINT DF_Expression_CannedExpressions_Archived DEFAULT (0),
    SourceAppID      INT          NULL,
    ClientAccountID  INT          NULL,
    AppDomainID      INT          NULL,
    DataDomainID     INT          NULL,
    DataSegmentID    INT          NULL,
    ResID            UNIQUEIDENTIFIER NULL,

    CONSTRAINT UQ_Expression_CannedExpressions_TenantAppName
        UNIQUE (TenantID, AppID, Name)
);

ICannedExpressionStore

public interface ICannedExpressionStore
{
    /// Returns the expression string for the given alias, or null if not found.
    /// Applies resolution order: (TenantId, AppId, Name) → (TenantId, NULL, Name)
    Task<string?> GetAsync(
        string name,
        string tenantId,
        string? appId,
        CancellationToken ct);

    /// Returns all active canned expressions for a tenant (for discovery/UI)
    Task<IReadOnlyList<CannedExpressionEntry>> GetAllAsync(
        string tenantId,
        string? appId,
        CancellationToken ct);
}

How the @ Directive Works

public class CannedExpressionDirectiveService : BaseDirectiveEvaluator
{
    public override string DirectiveName => "@";

    protected override async Task<EvaluationResponse> EvaluateAsync(
        EvaluationRequest request,
        CancellationToken ct)
    {
        var aliasName = request.Path;      // e.g., "company.currency"

        // Cycle detection — prevent @loopA → @loopB → @loopA
        if (request.VisitedKeys.Contains(aliasName))
            return EvaluationResponse.Failure(
                EvaluationErrorCode.CycleDetected,
                $"Cycle detected: alias '{aliasName}' has already been visited");

        var storedExpr = await _store.GetAsync(
            aliasName,
            Context.TenantId,
            Context.AppId,
            ct);

        if (storedExpr is null)
            return EvaluationResponse.Failure(
                EvaluationErrorCode.CannedExpressionNotFound,
                $"Canned expression '@{aliasName}' not found");

        // Chain: re-enter full pipeline with stored expression
        GuardChainDepth();
        return await ChainExpressionAsync(storedExpr, ct);
    }
}

Common Patterns

Pattern 1 — Tenant Constants

AliasStored ExpressionDescription
@company.currency{@ $ctx.tenant.currency }Tenant default currency
@company.timezone{@ $ctx.tenant.timezone }Tenant timezone
@company.supportEmailsupport@acme.comStatic value
@company.taxRate0.20Static tax rate (configurable without code)

Pattern 2 — Cross-App Overrides

-- All apps (AppId=NULL): @tax.rate = "0.20"
-- Ireland app (AppId=7): @tax.rate = "0.23"
-- US app (AppId=3):      @tax.rate = "0.00"  (tax-exempt entity)

Pattern 3 — Composed Aliases

-- @billing.recipient = "@company.billingContact"
-- @company.billingContact = "{@ $ctx.tenant.billingEmail @lowercase }"
-- Chain: @billing.recipient → @company.billingContact → $ctx.tenant.billingEmail

Managing via API

Canned expressions are managed through a CRUD REST API. Common operations:

// Create a new canned expression
POST /api/expressions/canned
{
  "tenantId": 5,
  "appId": null,             // null = tenant-wide
  "name": "company.currency",
  "expression": "{@ $ctx.tenant.currency }",
  "description": "Tenant default currency code",
  "tags": ["finance", "global"]
}

// Update (changing logic without touching any workflow)
PUT /api/expressions/canned/{id}
{
  "expression": "EUR"         // switch from dynamic to static
}

// Preview — evaluate the expression without a workflow context
POST /api/expressions/canned/{id}/preview
{
  "tenantId": 5,
  "appId": 7
}