Entities and keys
Entities give keys business identity, so FeatureQL can align features and validate relationships without relying on matching column names.
Declare the business object and its key
Declare an entity with ENTITY(), then annotate its key with #:
CUSTOMERS := ENTITY(),
CUSTOMER_ID := INPUT(BIGINT#CUSTOMERS),
BIGINT#CUSTOMERS means “a BIGINT that identifies a customer.” The annotation is part of the FeatureQL type and is checked by the compiler, but the generated SQL still uses an ordinary BIGINT. COALESCE / IF / CASE reject different entity families. Different roles of one family return the shared family. Drop an entity with KEY_TO_SCALAR().
Use plural entity names such as CUSTOMERS and ORDERS. Entity keys may use integer types (TINYINT, SMALLINT, INT, BIGINT), VARCHAR, DATE, TIMESTAMP (including time zone), GEOM_POINT, GEOG_POINT, or a ROW of those types. An ARRAY cannot identify an entity — composite keys are a ROW, not an array of key parts. DECIMAL, BOOLEAN, INTERVAL, floating-point types, and other scalars cannot carry #.
Repeat the annotation at the mapping boundary
The INPUT() and the source key bound to it must identify the same entity:
DEFINE
orders := ENTITY(),
order_id := INPUT(BIGINT#orders),
fct := INLINE_COLUMNS(
order_id BIGINT#orders BIND TO order_id,
amount DOUBLE
FROM CSV(
order_id,amount
10,99.9
11,50.0
)
),
order_fk := fct[order_id]
RETURN
order_id,
order_fk
FOR
order_id := BIND_VALUES(ARRAY(10, 11))
;| ORDER_ID BIGINT#ORDERS | ORDER_FK BIGINT#ORDERS |
|---|---|
| 10 | 10 |
| 11 | 11 |
The example uses INLINE_COLUMNS so it runs in the browser. A warehouse mapping uses the same declaration with EXTERNAL_COLUMNS:
ORDERS := ENTITY(),
ORDER_ID := INPUT(BIGINT#ORDERS),
ORDER_FACTS := EXTERNAL_COLUMNS(
order_id BIGINT#ORDERS BIND TO ORDER_ID,
customer_id BIGINT#CUSTOMERS,
amount DECIMAL(18,2)@CURRENCY.USD,
FROM TABLE(sales.orders)
),
Every source primary-key column must BIND TO its matching input. The combined values of all BIND TO columns must be unique — that is an authoring contract, not a compile-time primary-key check. Duplicate keys silently fan out. Model a composite primary key either as one ROW-typed key or by binding each key column:
product_id BIGINT#PRODUCTS BIND TO PRODUCT_ID,
day_id DATE#DAYS BIND TO DAY_ID,
Foreign keys that are not part of the source primary key carry their entity annotation without BIND TO. A value that repeats below the declared primary key can use DETERMINED BY; choose its aggregation contribution with Aggregation multiplicity .
What entity identity enables
Entity annotations support three compiler decisions:
- Alignment: features using the same entity input can be computed together.
- Relationships:
RELATED()andEXTEND()verify that the source and destination keys identify compatible entities. - Grain and counting: grouped results retain entity-aware grain, and
COUNT(DISTINCT order_id)over an entity-annotated key produces a count unit such as@ORDERS. BareCOUNT()is a row count and stays unlabelled.
An annotation does not declare a physical table, a join, or uniqueness. It identifies a business object; mappings and relationship operations describe how data for that object is reached.
See Mapping data sources , RELATED , and EXTEND for those operations.
Keep entity roles distinct
Two keys may share the same physical datatype but identify different entities:
CUSTOMER_ID := INPUT(BIGINT#CUSTOMERS),
ORDER_ID := INPUT(BIGINT#ORDERS),
FeatureQL rejects a relationship that substitutes one for the other. When the same entity appears in several roles, annotate each role key with the same entity and keep the role in the feature name:
ORDER_DATE := INPUT(DATE#DAYS),
SHIP_DATE := INPUT(DATE#DAYS),
Metric declarations name the key feature, not only its entity. For example, NOT ADDITIVE ACROSS (SHIP_DATE) remains distinct from ORDER_DATE even though both identify DAYS.
Cast entity annotations deliberately
Regular CAST() may add an entity annotation to an unannotated value. Drop a known identity with KEY_TO_SCALAR(). Change one known identity for another with UNSAFE_CAST():
DEFINE
entity1 := ENTITY(),
entity2 := ENTITY(),
entity1_id := INPUT(BIGINT#entity1)
RETURN
entity1_id,
plain_id := KEY_TO_SCALAR(entity1_id),
key_for_entity2 := UNSAFE_CAST(entity1_id AS _#entity2),
t_annotated := TYPEOF(entity1_id),
t_plain := TYPEOF(plain_id),
t_remapped := TYPEOF(key_for_entity2),
next_annotated_id := UNSAFE_CAST(KEY_TO_SCALAR(entity1_id) + 1 AS _#entity1),
t_next := TYPEOF(next_annotated_id)
FOR
entity1_id := BIND_VALUES(SEQUENCE(1, 3))
;| ENTITY1_ID BIGINT#ENTITY1 | PLAIN_ID BIGINT | KEY_FOR_ENTITY2 BIGINT#ENTITY2 | T_ANNOTATED VARCHAR | T_PLAIN VARCHAR | T_REMAPPED VARCHAR | NEXT_ANNOTATED_ID BIGINT#ENTITY1 | T_NEXT VARCHAR |
|---|---|---|---|---|---|---|---|
| 1 | 1 | 1 | BIGINT#ENTITY1 | BIGINT | BIGINT#ENTITY2 | 2 | BIGINT#ENTITY1 |
| 2 | 2 | 2 | BIGINT#ENTITY1 | BIGINT | BIGINT#ENTITY2 | 3 | BIGINT#ENTITY1 |
| 3 | 3 | 3 | BIGINT#ENTITY1 | BIGINT | BIGINT#ENTITY2 | 4 | BIGINT#ENTITY1 |
After AS, _ keeps the datatype:
- Add identity:
CAST(raw_id AS _#CUSTOMERS) - Drop identity:
KEY_TO_SCALAR(customer_id) - Change identity:
UNSAFE_CAST(customer_id AS _#ORDERS)
Omitting # keeps the existing entity annotation; it does not erase it. Repeatedly changing entity identity with UNSAFE_CAST() usually indicates that the entity boundaries need correction.
DATE_TRUNC on an entity-annotated date drops #E and warns: the truncated value is likely a coarser grain. Re-attach the intended role with CAST(DATE_TRUNC(order_date, 'year') AS _#DAYS.YEAR). DATE_ADD keeps the axis annotation.
Compare keys without losing identity
Equality (= / !=) accepts an entity key compared to a plain literal. Integer and string keys behave the same way. Order two identity keys with IS_KEY_BEFORE(). A key against a plain bound uses KEY_TO_SCALAR(). DATE#, TIMESTAMP#, and geography keys already accept <. Label the literal with CAST(1 AS _#ORDERS) when you want the comparison to carry the entity:
DEFINE
entity1 := ENTITY(),
entity1_id := INPUT(BIGINT#entity1)
RETURN
entity1_id,
is_one := entity1_id = 1,
is_not_one := entity1_id != 1,
gt_one := KEY_TO_SCALAR(entity1_id) > 1
FOR
entity1_id := BIND_VALUES(SEQUENCE(1, 3))
;| ENTITY1_ID BIGINT#ENTITY1 | IS_ONE BOOLEAN | IS_NOT_ONE BOOLEAN | GT_ONE BOOLEAN |
|---|---|---|---|
| 1 | TRUE | FALSE | FALSE |
| 2 | FALSE | TRUE | TRUE |
| 3 | FALSE | TRUE | TRUE |
String keys use the same equality rule:
DEFINE
entity1 := ENTITY(),
addr := INPUT(VARCHAR#entity1)
RETURN
is_abc := addr = 'abc'
FOR
addr := BIND_VALUES(ARRAY('abc', 'xyz'))
;| IS_ABC BOOLEAN |
|---|
| TRUE |
| FALSE |
Label the literal when the comparison should carry the entity:
DEFINE
entity1 := ENTITY(),
entity1_id := INPUT(BIGINT#entity1)
RETURN
is_one := entity1_id = 1::_#entity1,
t_labelled := TYPEOF(1::_#entity1)
FOR
entity1_id := BIND_VALUES(SEQUENCE(1, 3))
;| IS_ONE BOOLEAN | T_LABELLED VARCHAR |
|---|---|
| TRUE | BIGINT#ENTITY1 |
| FALSE | BIGINT#ENTITY1 |
| FALSE | BIGINT#ENTITY1 |
Ordering and range comparisons on identity keys (integers, VARCHAR) still require the annotation to be removed explicitly. Calendar and geo axes (DATE#DAYS, TIMESTAMP#KNOWLEDGE) may use range comparison and DATE_ADD without erase.
Cross-family equality (order_id = item_id) remains UE/ANNOTATIONS-NOT-ALLOWED.
Next
- Semantic modeling — see entities alongside units, multiplicity, and additivity
- Composability and purity — keep
RELATED()a function of the parent key - Mapping data sources — connect entity keys to external data
- RELATED — aggregate across entity relationships
- Units and measures — describe what numeric values measure