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.
TRANSFORMscope is an inner query over an array of rows. It sees only the fields of that array. It cannot reference outer features, callEXTERNAL_*,EXTENDorRELATED, or contain anotherTRANSFORM.
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;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:
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
;| 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:
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
;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:
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
;| CUSTOMER_ID BIGINT#CUSTOMERS | MAX_ORDER DECIMAL(38,2) | BIG_ORDERS BIGINT |
|---|---|---|
| 1 | 60.00 | 1 |
| 2 | 64.00 | 1 |
Termination
The dependency graph is acyclic, and this is checked:
DEFINE
a := b + 1,
b := a + 1
RETURN
a
;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:
DEFINE
SEED := 0,
COUNTER := ROW(SEED AS n).RECURSE(
RETURN prev[n] + 1 AS n
WHILE n < 5
),
RETURN COUNTER;The bound must be a compile-time constant. It cannot come from data:
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)
;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.
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()
;| N_STEPS BIGINT | LAST_N BIGINT | MAX_LVL BIGINT |
|---|---|---|
| 5 | 4 | 4 |
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 .