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:

FeatureQL
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))
;
Result
ORDER_ID BIGINT#ORDERSORDER_FK BIGINT#ORDERS
1010
1111

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:

  1. Alignment: features using the same entity input can be computed together.
  2. Relationships: RELATED() and EXTEND() verify that the source and destination keys identify compatible entities.
  3. 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. Bare COUNT() 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():

FeatureQL
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))
;
Result
ENTITY1_ID BIGINT#ENTITY1PLAIN_ID BIGINTKEY_FOR_ENTITY2 BIGINT#ENTITY2T_ANNOTATED VARCHART_PLAIN VARCHART_REMAPPED VARCHARNEXT_ANNOTATED_ID BIGINT#ENTITY1T_NEXT VARCHAR
111BIGINT#ENTITY1BIGINTBIGINT#ENTITY22BIGINT#ENTITY1
222BIGINT#ENTITY1BIGINTBIGINT#ENTITY23BIGINT#ENTITY1
333BIGINT#ENTITY1BIGINTBIGINT#ENTITY24BIGINT#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:

FeatureQL
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))
;
Result
ENTITY1_ID BIGINT#ENTITY1IS_ONE BOOLEANIS_NOT_ONE BOOLEANGT_ONE BOOLEAN
1TRUEFALSEFALSE
2FALSETRUETRUE
3FALSETRUETRUE

String keys use the same equality rule:

FeatureQL
DEFINE
    entity1 := ENTITY(),
    addr := INPUT(VARCHAR#entity1)
RETURN
    is_abc := addr = 'abc'
FOR
    addr := BIND_VALUES(ARRAY('abc', 'xyz'))
;
Result
IS_ABC BOOLEAN
TRUE
FALSE

Label the literal when the comparison should carry the entity:

FeatureQL
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))
;
Result
IS_ONE BOOLEANT_LABELLED VARCHAR
TRUEBIGINT#ENTITY1
FALSEBIGINT#ENTITY1
FALSEBIGINT#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