Scopes and termination

FeatureQL limits two things that SQL leaves open: how deep a query can nest, and how long it can iterate.

Two scopes

SQL nests without limit. FeatureQL has two scope levels.

  • Graph scope holds all features as peers in a flat namespace.
  • TRANSFORM scope is an inner query over an array of rows. It sees only the fields of that array. It cannot reference outer features, call EXTERNAL_*, EXTEND or RELATED, or contain another TRANSFORM.

FeatureQL
DEFINE
    ORDERS := ARRAY[
        ROW(101 AS order_id, ARRAY[ROW('A' AS sku, 2 AS qty, 10.00 AS price)] AS lines)
    ],
    REV := ORDERS.TRANSFORM(RETURN order_id, lines.TRANSFORM(RETURN SUM(qty * price)) AS rev),
RETURN REV;
Result
ERROR UE/TRANSFORM-FORBIDDEN-TRANSFORM TRANSFORM() inner queries cannot include TRANSFORM(); use successive TRANSFORM() calls on the array instead (for example arr.TRANSFORM(SELECT field AS x).TRANSFORM(PERSIST ...)), or flatten nested arrays with ARRAY_MERGE and FLATTEN().

The rule is: fetch and join in graph scope, compute in transform scope. Every dependency of a feature is then visible at graph level, and no nested scope has to be traced to know what a feature reads. See TRANSFORM .

What replaces nesting

Nesting in a query language does three jobs. Each has its own mechanism here.

Naming intermediate results. They are named features in the flat graph.

Using an outer value inside. The value is attached to the rows with CARRY, related data is added with EXTEND at graph level, and a definition is brought in with IMPORT. The injection is explicit, and the carried field leaves the row type again on the next projection:

FeatureQL
DEFINE
    threshold := 100.00,
    arr := ARRAY(ROW(1, 50.00), ROW(2, 150.00), ROW(3, 250.00))::ARRAY<
        ROW<id BIGINT, amount DECIMAL(10,2)>
    >,
    filtered := CARRY(threshold AS th INTO arr).TRANSFORM(
        RETURN *
        WHERE amount > th
    ),
    cleaned := filtered.TRANSFORM(
        RETURN
            id,
            amount
    )
RETURN
    with_carry := filtered,
    dropped := cleaned
;
Result
WITH_CARRY ARRAY<ROW<id BIGINT, amount DECIMAL(10,2), th DECIMAL(5,2)>>DROPPED ARRAY<ROW<id BIGINT, amount DECIMAL(10,2)>>
[{id: 2, amount: 150.00, th: 100.00}, {id: 3, amount: 250.00, th: 100.00}][{id: 2, amount: 150.00}, {id: 3, amount: 250.00}]

A name collision is refused. An outer name cannot silently shadow a row field:

FeatureQL
DEFINE
    arr := ARRAY(ROW(1e0 AS amount), ROW(2e0 AS amount)),
    thresh := 99e0,
    shadowed := CARRY(thresh AS amount INTO arr)
RETURN
    original := arr,
    after_carry := shadowed
;
Result
ERROR UE/CARRY-DUPLICATE-FIELD CARRY() cannot add field 'amount' because that name already exists on the base row when defining feature SHADOWED.

Changing grain. This goes through UNNEST or chained TRANSFORM calls.

In many cases, depth in the data does not become depth in the query at all. Customers have orders, and orders have lines. In SQL this is nested aggregation. Here it is two hops between flat definitions:

FeatureQL
DEFINE
    customers := ENTITY(),
    orders := ENTITY(),
    lines := ENTITY(),
    customer_id := INPUT(BIGINT#customers),
    order_id := INPUT(BIGINT#orders),
    line_id := INPUT(BIGINT#lines),
    ord := INLINE_COLUMNS(
        order_id BIGINT#orders BIND TO order_id,
        customer_id BIGINT#customers
        FROM JSON([
            {"order_id": 101, "customer_id": 1},
            {"order_id": 102, "customer_id": 1},
            {"order_id": 103, "customer_id": 2}
        ])
    ),
    ln := INLINE_COLUMNS(
        line_id BIGINT#lines BIND TO line_id,
        order_id BIGINT#orders,
        qty BIGINT,
        price DECIMAL(10,2)
        FROM JSON([
            {"line_id": 1, "order_id": 101, "qty": 2, "price": 10.00},
            {"line_id": 2, "order_id": 101, "qty": 1, "price": 5.00},
            {"line_id": 3, "order_id": 102, "qty": 3, "price": 20.00},
            {"line_id": 4, "order_id": 103, "qty": 1, "price": 4.00},
            {"line_id": 5, "order_id": 103, "qty": 2, "price": 30.00}
        ])
    ),
    order_revenue := order_id.RELATED(
        SUM(ln[qty] * ln[price]) GROUP BY (ln[order_id])
    ),
    max_order := customer_id.RELATED(
        MAX(order_revenue) GROUP BY (ord[customer_id])
    ),
    big_orders := customer_id.RELATED(
        COUNT_IF(order_revenue > 30.00) GROUP BY (ord[customer_id])
    )
RETURN
    customer_id,
    max_order,
    big_orders
FOR
    customer_id := BIND_VALUES(ARRAY(1, 2)),
    NESTED order_id := BIND_VALUES(ARRAY(101, 102, 103)),
    NESTED line_id := BIND_VALUES(ARRAY(1, 2, 3, 4, 5))
ORDER BY customer_id
;
Result
CUSTOMER_ID BIGINT#CUSTOMERSMAX_ORDER DECIMAL(38,2)BIG_ORDERS BIGINT
160.001
264.001

Termination

The dependency graph is acyclic, and this is checked:

FeatureQL
DEFINE
    a := b + 1,
    b := a + 1
RETURN
    a
;
Result
ERROR UE/CYCLE-DETECTED A dependency cycle is not allowed; this cycle was detected: A, B. Change a definition so it does not refer back to itself.

There are no loops in the core language, so evaluation follows a finite order and terminates.

RECURSE is the one construct that iterates, for running balances and hierarchy traversal. It is available on DuckDB. Its depth bound is mandatory:

FeatureQL
DEFINE
    SEED := 0,
    COUNTER := ROW(SEED AS n).RECURSE(
        RETURN prev[n] + 1 AS n
        WHILE n < 5
    ),
RETURN COUNTER;
Result
ERROR UE/SYNTAX-ERROR Syntax error at line 6:4 - mismatched input ')' (offending symbol: ')')

The bound must be a compile-time constant. It cannot come from data:

FeatureQL
DEFINE
    depth := INPUT(BIGINT),
    counter := ROW(0 AS n).RECURSE(
        RETURN n := prev[n] + 1
        WHILE n < 1000
        MAX_LEVEL depth
    )
RETURN
    counter
FOR
    depth := BIND_VALUE(4)
;
Result
ERROR UE/RECURSE-MAX-LEVEL-NOT-FOLDABLE MAX_LEVEL must be a compile-time integer constant when defining feature COUNTER.

A WHILE condition is evaluated at each level and can stop the iteration before the bound. Reaching the bound while WHILE still holds is a runtime error (RECURSE-MAX-LEVEL-REACHED), not a shorter result. Without WHILE, the iteration runs to MAX_LEVEL.

FeatureQL
CONST CAP := 10
DEFINE
    counter := ROW(0 AS n).RECURSE(
        RETURN n := prev[n] + 1
        WHILE n < 5
        MAX_LEVEL @eval_as_literal(cap)
    )
RETURN
    n_steps := ARRAY_COUNT(counter),
    last_n := counter.TRANSFORM(RETURN MAX(n)).UNWRAP_ONE(),
    max_lvl := counter.TRANSFORM(RETURN MAX(level)).UNWRAP_ONE()
;
Result
N_STEPS BIGINTLAST_N BIGINTMAX_LVL BIGINT
544

What this means

FeatureQL is not Turing complete. The number of evaluation steps is fixed by the query text: the graph is acyclic, and every iteration has a constant ceiling. The amount of work still depends on the data, since a bounded number of passes over a large table is a large amount of work.

Two practical consequences:

  • A rule on a request path cannot loop forever.
  • A generated query cannot fail to terminate, which matters when code is written by a model. See FeatureQL and language models .