Operators

Expression

Compute derived fields from named inputs with sandboxed Jinja2 expressions — typed results, pass-through, missing-input semantics, and the YAML form.

Expression computes new fields from its inputs. Each output is a Jinja2 expression — the body of a template variable without the braces — evaluated with the node's inputs as its names. The expression returns a value, not rendered text: price * quantity is a number, [a, b] is a list, 'A' if score < 0.5 else 'B' is a string. Use it where a Python node would be a one-liner: a ratio, a rounded total, a tier label, a flag.

A first expression

from dynamiq.nodes.operators import Expression
from dynamiq.nodes.types import ExpressionItem, NamedField
from dynamiq.runnables import RunnableConfig

pricing = Expression(
    name="pricing",
    input_fields=[NamedField(name="price"), NamedField(name="quantity")],
    expressions=[
        ExpressionItem(key="total", expression="(price * quantity) | round(2)"),
        ExpressionItem(key="tier", expression="'bulk' if quantity >= 10 else 'single'"),
        ExpressionItem(key="items", expression="[price, quantity] | map('float') | list"),
    ],
)

result = pricing.run(input_data={"price": 2.5, "quantity": 12}, config=RunnableConfig(callbacks=[]))
print(result.output)
# {'total': 30.0, 'tier': 'bulk', 'items': [2.5, 12.0]}

Configuration

namestr
Node name; defaults to "expression".
input_fieldslist[NamedField]
The inputs by name, for the editor and for documentation. Evaluation uses every key the node actually receives, declared or not.
expressionslist[ExpressionItem]
One output per item: key is the output name (a valid Python identifier, unique in the node), expression the Jinja2 expression that computes it.
pass_throughbool
Copy every input into the output next to the computed fields. An expression with the same key as an input wins. Default: False.

ExpressionItem(id, key, expression) and NamedField(id, name, type) come from dynamiq.nodes.types; id is generated when omitted.

What an expression can do

Anything a Jinja2 expression can: arithmetic and comparisons, string and list literals, if / else, and / or / not, in, attribute and item access on the inputs (applicant.fico, scores[0]; a record's key is read before a method of the same name, so invoice.items is the record's list), and the built-in filters — round, abs, length, lower, upper, trim, default, map, select, sum, min, max, join, int, float, string, list, and the rest of the Jinja2 filter set. The helpers a rule can call are available too: has(value) (present and not null), days_between(start, end), date(value) (an ISO or MM/DD/YYYY string, a date or a datetime), today(), len, abs, min, max, sum and round, so a due date or an age is one expression. Every expression runs in one module-level ImmutableSandboxedEnvironment, so it cannot mutate its inputs, reach Python internals through attributes such as __class__, or run an unbounded range.

ExpressionResult for {"amount": 250, "region": "EU", "scores": [3, 5, 4]}
amount * 1.2300.0
(amount * 0.2) | round(2)50.0
'high' if amount > 200 else 'low''high'
region in ['EU', 'UK']True
scores | max5
(scores | sum) / (scores | length)4.0
discount | default(0)0 (default replaces a name that is missing; a present None stays None)
(discount or 0)0, whether discount is missing or None

Missing inputs and errors

  • An input referred to on its own that is missing evaluates to None (discount → None), so a plain reference never fails. An input missing inside a list or a dict the expression builds is None there too ([price, discount] → [2.5, None]), so the output stays serializable. An expression left lazy, items | map(attribute='price') without a closing | list, returns a list rather than a generator, for the same reason.
  • A missing input used inside an operation fails the run: price * quantity without quantity raises 'quantity' is undefined. An input that is present but None fails the same way: base_rate + llpa raises a TypeError when llpa is None.
  • Guard such inputs with (x or 0), which covers both cases. | default(0) replaces only a name that is missing — Jinja2's default acts on an undefined name, and a present None passes through it — so a null from upstream still reaches the arithmetic; | default(0, true) also replaces None and every other falsy value.
  • An expression that reaches for internals is refused by the sandbox at run time.
  • An input named like a helper (date) is the input where an expression reads it as a value and the helper where an expression calls it; an expression that does both is refused at construction.
  • An input named self passes through but cannot be read: Jinja reserves the name inside an expression, so an expression reading it is refused at construction; a self key inside a record (payload.self.href) reads like any other.
  • A syntax error, a key that is not an identifier, or a key used twice fails at construction — or at YAML load — with a message naming the node and the key.
from dynamiq.nodes.operators import Expression
from dynamiq.nodes.types import ExpressionItem

try:
    Expression(name="ratio", expressions=[ExpressionItem(key="ltv", expression="loan / /")])
except ValueError as error:
    print(error)
# Expression 'ratio': 'ltv' is not a valid expression: unexpected '/'

Run-time failures go through the node's error handling like any other node's.

Pass-through

With pass_through=True the output holds every input next to the computed fields, so a later node can read both from one place. An expression with the same key as an input replaces it:

from dynamiq.nodes.operators import Expression
from dynamiq.nodes.types import ExpressionItem
from dynamiq.runnables import RunnableConfig

bump = Expression(
    name="bump",
    pass_through=True,
    expressions=[ExpressionItem(key="quantity", expression="quantity + 1")],
)
print(bump.run(input_data={"price": 2.5, "quantity": 1}, config=RunnableConfig(callbacks=[])).output)
# {'price': 2.5, 'quantity': 2}

Inside a flow

An expression reads its inputs from upstream nodes through the usual mapping. Here it computes a rate from the workflow input and from a decision table's collect output:

from dynamiq import Workflow
from dynamiq.flows import Flow
from dynamiq.nodes import InputTransformer
from dynamiq.nodes.node import NodeDependency
from dynamiq.nodes.operators import DecisionTable, Expression
from dynamiq.nodes.types import DecisionRule, ExpressionItem, NamedField
from dynamiq.nodes.utils import Input, Output
from dynamiq.runnables import RunnableConfig

start = Input(id="start", name="start")
adjustments = DecisionTable(
    id="adjustments",
    name="adjustments",
    hit_policy="collect",
    aggregation="sum",
    input_columns=[NamedField(name="fico", type="int"), NamedField(name="ltv", type="int")],
    output_columns=[NamedField(name="llpa", type="float")],
    rules=[
        DecisionRule(id="a1", name="mid fico", when=["[680..739]", ""], then=["0.75"]),
        DecisionRule(id="a2", name="high ltv", when=["", "> 80"], then=["0.25"]),
    ],
    depends=[NodeDependency(node=start)],
    input_transformer=InputTransformer(selector={"fico": "$.start.output.fico", "ltv": "$.start.output.ltv"}),
)
rate = Expression(
    id="rate",
    name="rate",
    input_fields=[NamedField(name="base_rate"), NamedField(name="llpa")],
    expressions=[
        ExpressionItem(key="rate", expression="(base_rate + (llpa or 0)) | round(3)"),
        ExpressionItem(key="tier", expression="'A' if (llpa or 0) < 0.5 else 'B'"),
    ],
    depends=[NodeDependency(node=start), NodeDependency(node=adjustments)],
    input_transformer=InputTransformer(
        selector={"base_rate": "$.start.output.base_rate", "llpa": "$.adjustments.output.llpa"}
    ),
)
end = Output(
    id="end",
    name="end",
    depends=[NodeDependency(node=rate)],
    input_transformer=InputTransformer(selector={"rate": "$.rate.output.rate", "tier": "$.rate.output.tier"}),
)

workflow = Workflow(flow=Flow(nodes=[start, adjustments, rate, end]))
result = workflow.run(
    input_data={"fico": 700, "ltv": 85, "base_rate": 6.5},
    config=RunnableConfig(callbacks=[]),
)
print(result.output["end"]["output"])
# {'rate': 7.5, 'tier': 'B'}

llpa or 0 guards the case where no adjustment matched: a collect table with sum returns None when no rule fired, and llpa | default(0) would let that None through into the addition.

YAML

nodes:
  rate:
    type: dynamiq.nodes.operators.Expression
    name: rate
    input_fields:
      - { id: base_rate, name: base_rate }
      - { id: llpa, name: llpa }
    expressions:
      - { id: x1, key: rate, expression: "(base_rate + (llpa or 0)) | round(3)" }
      - { id: x2, key: tier, expression: "'A' if (llpa or 0) < 0.5 else 'B'" }
    pass_through: false
    depends:
      - node: start
      - node: adjustments
    input_transformer:
      selector:
        base_rate: $.start.output.base_rate
        llpa: $.adjustments.output.llpa

Quote every expression: |, ', {, and [ all mean something to YAML.

On this page