LLM reference — FeatureQL syntax
Complete syntax reference for writing FeatureQL queries. Load this page when you need to write or debug query structure, types, casting, operators, or expression syntax.
Query anatomy
CONST -- Optional: compile-time constants
MY_CONST := 42,
WITH -- Local features (only evaluated if referenced in SELECT)
FEATURE1 := INPUT(BIGINT),
FEATURE2 := FEATURE1 * 2,
SELECT -- Features to return in output
FEATURE1,
FEATURE2,
FROM fm.namespace -- Optional: pull persisted features from registry
FOR -- Required when SELECT depends on INPUT(); omit if no INPUT() in that graph
FEATURE1 := BIND_VALUES(ARRAY[1, 2, 3]),
WHERE FEATURE2 > 2 -- Optional: filter result rows
ORDER BY FEATURE2 DESC -- Optional: sort
LIMIT 10 OFFSET 5 -- Optional: pagination
; Key rules:
WITHfeatures are not in the output.SELECTfeatures are in the output.- Features can reference each other in any order — dependency resolution is automatic.
FROM fm.namespaceimports persisted features so you can reference them by short name.FOR: required iff anyINPUT()appears in the dependency graph of yourSELECT(directly or through other features). Literal-only queries with noINPUT()may omitFOR. Multiple independentBIND_*clauses needFOR CROSSwhen you intend a Cartesian product — see Binding inputs .- Trailing commas are allowed everywhere.
Assignment styles
Both are equivalent. The := style is preferred:
NUMBER1 := 1 -- FeatureQL style (preferred)
1 AS NUMBER1 -- SQL style Type system
No automatic coercion — cast explicitly with ::TYPE or CAST(x AS TYPE).
Scalar types
| Category | Types | Aliases | Literal examples |
|---|---|---|---|
| Integer | BIGINT, INT, SMALLINT, TINYINT | INT64, INT32, INTEGER, INT16, INT8 | 42, -7 |
| Decimal | DECIMAL | — | 1.25 (auto-detects precision: DECIMAL(3,2)) |
| Float | FLOAT, DOUBLE | FLOAT32, FLOAT64 | 1.25e0 (scientific notation) |
| String | VARCHAR | — | 'text' (single quotes only) |
| Boolean | BOOLEAN | — | TRUE, FALSE |
| Date | DATE | — | DATE '2024-10-15' |
| Timestamp | TIMESTAMP | — | TIMESTAMP '2024-10-15 10:04:00' |
| Interval | INTERVAL | — | INTERVAL '3 DAY' (quantity and unit in one string; not INTERVAL '3' DAY) |
| JSON | JSON | — | JSON '"value"' |
Complex types
| Type | Alias | Literal example |
|---|---|---|
ARRAY(T) | LIST(T) | ARRAY[1, 2, 3] |
ROW(field1 T1, field2 T2) | STRUCT(...) | ROW(1, 'A' AS name) |
ARRAY(ROW(...)) | — | ARRAY[ROW(1, 'A'), ROW(2, 'B')]::ARRAY(ROW(num BIGINT, letter VARCHAR)) |
Entity annotations
Entity annotations link types to semantic entities for relationship tracking:
BIGINT#CUSTOMERS -- This value is a CUSTOMERS entity key
ARRAY(BIGINT#ORDERS) -- Array of ORDER entity keys Special values
| Value | Meaning |
|---|---|
NULL | Untyped null |
NULL(DOUBLE) | Typed null |
EMPTY | Explicitly empty (cannot be cast) |
Casting
CAST('123' AS BIGINT) -- Standard form
'123'::BIGINT -- Shorthand (higher precedence than operators)
ID::BIGINT#ORDERS -- Add entity annotation
ID::BIGINT -- Remove entity annotation Critical type pitfalls
DECIMAL widening works everywhere. Different DECIMAL precisions are unified (widened) automatically in arithmetic, arrays, ROWs, and constructors: ARRAY[1.25, 1.1] produces ARRAY(DECIMAL(3,2)).
Integer promotion to DECIMAL is limited. When a BIGINT literal appears next to a DECIMAL in arithmetic, comparisons, or BETWEEN, it is promoted to DECIMAL(N,0). This promotion does not apply inside ARRAY or ROW constructors — mixing BIGINT and DECIMAL elements is an error.
BETWEEN is strict: the value and both bounds must be the same types (e.g. all DOUBLE with e0 literals, or all BIGINT). Mixed BIGINT / DOUBLE fails.
-- OK: DECIMAL widening in arrays and ROWs (same family, different precision)
arr := ARRAY[1.25, 1.1] -- ARRAY(DECIMAL(3,2))
aor := ARRAY[ROW(1.1 AS val), ROW(1.25 AS val)] -- ARRAY(ROW(val DECIMAL(3,2)))
-- OK: integer widening in arrays (all integers, different sizes)
arr := ARRAY[ROW(2::BIGINT), ROW(INT '2')] -- ARRAY(ROW(field_1 BIGINT))
-- WRONG: BIGINT + DECIMAL in array (no promotion in constructors)
arr := ARRAY[1.25, 1] -- Error: DECIMAL + BIGINT are different families
-- WRONG: 1.25 is DECIMAL(3,2), price is DOUBLE → type mismatch
revenue := price * 1.25
-- RIGHT: Use scientific notation for DOUBLE literals
revenue := price * 1.25e0
-- RIGHT: Or cast explicitly
revenue := price * CAST(1.25 AS DOUBLE) The e0 suffix means "times 10 to the power 0" — mathematically a no-op, but it tells FeatureQL the literal is DOUBLE, not DECIMAL. Use e0 when you need DOUBLE values.
Operators (by precedence, highest first)
| Precedence | Operators | Description |
|---|---|---|
| 1 | := | Assignment |
| 2 | () | Parentheses |
| 3 | [] | Array/row extraction |
| 4 | :: | Type casting |
| 5 | . | Function chaining |
| 6 | ~ | Bitwise NOT |
| 7 | ^ ** | Power |
| 8 | * / // % | Multiply, divide, integer div, modulo |
| 9 | + - | Addition, subtraction |
| 10 | << >> | Bitwise shift left, bitwise shift right |
| 11 | & | Bitwise AND |
| 12 | | | Bitwise OR |
| 13 | || | String concatenation |
| 14 | = <> != > >= < <= BETWEEN NOT BETWEEN LIKE NOT LIKE IN NOT IN IS NULL IS NOT NULL | Comparison |
| 15 | NOT | Logical NOT |
| 16 | AND | Logical AND |
| 17 | OR | Logical OR |
Special operators
| Operator | Purpose | Example |
|---|---|---|
=.N | Float comparison with N digits precision | 0.99999e0 =.4 1.00001e0 → TRUE |
|| | String concatenation | 'Hello' || ' World' |
// | Type-safe division | 6.10 // 3 → 2.03 |
[] | Extract field from ROW or element from ARRAY | row[field_name], array[1] |
IS NULL / IS NOT NULL | Null checks | value IS NULL |
Function chaining
Any function can be called as a method using . syntax. The expression before the dot becomes the first argument:
' HELLO '.TRIM().LOWER() -- Equivalent to LOWER(TRIM(' HELLO '))
ARRAY[3,1,2].ARRAY_SORT().ARRAY_SUM() -- Chain array operations
(1 + 2).MULTIPLY(3) -- Equivalent to MULTIPLY((1 + 2), 3) Style preferences (readable, LLM-friendly code)
When you generate FeatureQL, default to forms that read like ordinary expressions: operators, SQL-style CASE, and left-to-right chains. Use function-call or “parallel array” forms when they are clearer or required by evaluation rules.
| Valid | Preferred (when equivalent) |
|---|---|
ADD(a, b), SUBTRACT, MULTIPLY, DIVIDE, and other numeric variadic call forms | a + b, a - b, a * b, a / b or a // b, a ^ b or a ** b |
CONCAT(s1, s2) (two pieces of text) | s1 || s2 |
LOWER(TRIM(x)), ARRAY_SUM(ARRAY_SORT(arr)), etc. | x.TRIM().LOWER(), arr.ARRAY_SORT().ARRAY_SUM() when the pipeline reads naturally |
CASE_WHEN(ARRAY[cond…], ARRAY[then…], else) with ordinary boolean branches | CASE WHEN … THEN … WHEN … THEN … ELSE … END |
Deep nesting F(G(H(x))) | Intermediate features in WITH, or method chaining on the inner value so readers scan in one direction |
Unary / variadic builtins with no operator (e.g. ROUND, COALESCE, ARRAY_LENGTH) | Keep the function — there is nothing to replace |
Conditionals: Prefer IF(cond, then, else) for a single ternary. Prefer SQL CASE (searched or simple) for multi-branch logic. Use CASE_WHEN / CASE_WHEN_VALUE when the logic is naturally “parallel arrays of conditions and results” (or when tests/registry examples require that shape); see SHOW TESTS if unsure.
Chaining: Prefer .method() when it matches “take this value, then this step, then this step” and avoids a parenthesis stack. If a chain gets long, split across WITH assignments.
Array and ROW access with []
The [] operator is overloaded based on context:
-- ROW field access
row_feature[field_name] -- Extract one field
row_feature[field1, field2] -- Extract multiple fields (returns ROW)
-- ARRAY element access (1-indexed)
array_feature[1] -- First element
array_feature[2:4] -- Slice (elements 2, 3, 4)
array_feature[2,4] -- Elements at positions 2 and 4
-- ARRAY(ROW) field extraction (returns ARRAY of that field's values)
array_of_rows[field_name] -- Extract field as array: [{id:1,name:'A'},{id:2,name:'B'}][name] → ['A','B'] Conditional expressions
LLM style: Use the SQL-style forms below for most hand-written and generated queries. Reserve CASE_WHEN / CASE_WHEN_VALUE (documented just after this block) for parallel-array idioms or when the registry/tests expect that shape — see Style preferences .
-- Ternary
IF(condition, then_value, else_value)
-- Multi-branch
CASE
WHEN condition1 THEN value1
WHEN condition2 THEN value2
ELSE default_value
END
-- Value matching
CASE expression
WHEN match1 THEN result1
WHEN match2 THEN result2
ELSE default_value
END
-- First non-null
COALESCE(value1, value2, value3) Functional forms (not CASE WHEN( — that starts SQL case and expects THEN):
CASE_WHEN(ARRAY[cond1, cond2], ARRAY[then1, then2], else)— first true condition picks the parallel branch. Thethenarray is often literal; dynamic shapes can hit evaluation-mode errors — seeSHOW TESTS WHERE FUNCTION_NAME = 'CASE_WHEN'.CASE_WHEN_VALUE(expr, ARRAY[match…], ARRAY[then…], else)— equality match / simple case; match and then arrays same length.
Metaprogramming
Use when you need compile-time evaluation or dynamic query construction:
CONST MY_VALUE := 42, -- Compile-time constant
-- Inject computed literal (preserves type, evaluated once at compile time)
THRESHOLD := @eval_as_literal(POWER(2, 10)),
-- Non-deterministic / compile-once scalars: wrap in @eval_as_literal(...) (not a separate @literal keyword)
TODAY := @eval_as_literal(CURRENT_TIMESTAMP()), -- Evaluated once, not per row
RAND := @eval_as_literal(RANDOM()), -- Evaluated once, not per row
-- Inject computed string into query text (treat like SQL string splice — only trusted inputs)
DYNAMIC := @eval_as_text('SELECT * FROM ' || table_name),
-- Deterministic per-row randomness (NOT @eval_as_literal — per-row by design)
BUCKET := HASH01(USER_ID, 'experiment_name'), -- Returns 0.0 to 1.0 ARRAY_MERGE join spelling
The grammar accepts ARRAY_MERGE(left, right JOIN ON key_field) (keywords JOIN ON). Older prose sometimes says JOINED ON; use JOIN ON in new queries.
Persistence (DDL)
CREATE FEATURES IN fm.namespace AS SELECT ...; -- Persist feature definitions
CREATE OR REPLACE FEATURES IN fm.namespace AS SELECT ...; -- Upsert
CREATE TEMPORARY FEATURES AS SELECT ...; -- Session-scoped
DROP FEATURES fm.namespace.feature_name; -- Remove
ALTER FEATURE fm.namespace.feature SET COMMENT = 'desc'; -- Add metadata Features are persisted as definitions (like SQL views), not data. FROM fm.namespace in a query imports them.
Namespace conventions
-- Features use fm.namespace.feature_name pattern
FROM fm.ecomm -- Import namespace, use short names
FROM fm.ns1, fm.ns2 AS _ns2 -- Multiple namespaces (alias must start with _)
fm.ecomm.CUSTOMER_NAME -- Fully qualified reference