Skip to content

Latest commit

 

History

History
192 lines (150 loc) · 8.3 KB

11.computed.md

File metadata and controls

192 lines (150 loc) · 8.3 KB
title Computed fields
description Compute a field in the database with @Computed, from the row or from a per-row subquery, and sort, filter, and page on it.
mode reference

Overview

The @Computed decorator declares a field whose value the database computes from an expression and merges onto each row at query time. The expression can compute from other fields of the same row, or run a per-row subquery such as an aggregate over another table. The merge happens before the list query sorts and filters, so the database sorts, filters, and pages computed fields. The API never fetches every row or sorts in memory.

Computed fields are meant to be read-only. The query produces the value, and the controller's table never stores it. @Computed sets the field's access mode to read-only when it runs. An @Access written above it runs later and replaces that mode, so do not combine the two. @Computed also makes the field's sort non-indexed, whatever the decorator order.

Basic usage

The expression receives the row proxy and the schema instance of the controller's table. It must return a value built with the query builder (ValueProxy operations or a subquery), not plain JavaScript computed at request time.

The example follows the quick-start controller pattern. Register the myapp schema before the module serves requests. Check driver support before you use aggregate subqueries.

import { Controller } from "@antelopejs/interface-api";
import {
  DataController,
  DefaultRoutes,
  RegisterDataController,
} from "@antelopejs/interface-data-api";
import {
  Access,
  AccessMode,
  Computed,
  Listable,
  ModelReference,
  Sortable,
} from "@antelopejs/interface-data-api/metadata";
import {
  BasicDataModel,
  Field,
  Index,
  Model,
  RegisterTable,
  Relation,
  Table,
} from "@antelopejs/interface-database-decorators";

@RegisterTable("groups", "myapp")
class Group extends Table {
  @Field("string")
  declare _id: string;

  @Field("string")
  declare name: string;
}

@RegisterTable("devices", "myapp")
class Device extends Table {
  @Field("string")
  declare _id: string;

  @Index()
  @Field("string")
  @Relation({ to: () => Group })
  declare group_id: string;
}

class GroupModel extends BasicDataModel(Group, "groups") {}

@RegisterDataController()
class GroupAPI extends DataController(
  Group,
  DefaultRoutes.All,
  Controller("/groups"),
) {
  @ModelReference()
  @Model(GroupModel)
  declare groupModel: GroupModel;

  @Listable()
  @Access(AccessMode.ReadOnly)
  declare _id: string;

  @Listable()
  @Access(AccessMode.ReadWrite)
  declare name: string;

  // Aggregate: number of devices referencing this group, computed in DB
  @Listable()
  @Computed(
    (row, db) =>
      db
        .table("devices")
        .filter((device) => device.key("group_id").eq(row.key("_id")))
        .count(),
    { default: 0 },
  )
  @Sortable()
  declare devices_count: number;

  // Row-local expression
  @Listable()
  @Computed((row) => row.key("name").concat("!"))
  declare display_name: string;
}

The row proxy types every field as a FieldValue. The subquery compares each device's group_id with the row's _id through eq, which accepts that type. This filter checks every device for each row and does not use the group_id index. getAll(row.key("_id"), "group_id") would use the index, but getAll does not accept a row key typed as a FieldValue, so it compiles only with a cast.

A GET request to /groups/get?id=group-123 returns the computed values at the top level:

{
  "_id": "group-123",
  "name": "Alpha",
  "devices_count": 3,
  "display_name": "Alpha!"
}

Decorator parameters

@Computed(expr, options?)
Parameter Type Required Description
expr (row, db) => expression Yes Expression receiving the row proxy and the schema instance. Returns a ValueProxy expression, a subquery (Query), or a constant
options.default FieldValue No Fallback merged over the computed value when the expression yields null

The default option

An aggregate subquery over an empty set yields null instead of its zero value. A count() over a group with no devices returns null, not 0. Calling .default(0) inside the subquery expression does not help, because the aggregation produces no document to apply the default to. The options.default value works because the Data API applies it to the row, after the merge:

@Computed(
  (row, db) =>
    db
      .table("devices")
      .filter((device) => device.key("group_id").eq(row.key("_id")))
      .count(),
  { default: 0 },
)
declare devices_count: number;

Without it, a row with an empty aggregate holds null. An equality filter such as filter_devices_count=eq:0 then misses the row, and the sort order depends on BSON type ordering.

Sorting, filtering, and pagination

Computed fields combine with @Sortable and @Filter like stored columns. The list query merges each computed field only where a step needs its value:

  • A computed field that an active filter reads is merged before the filters run.
  • A computed field used as the sort key is merged after the total count and before the non-indexed orderBy, so the count does not compute the aggregate.
  • A computed field requested only for display, in the pluck and neither sorted nor filtered, is merged after pagination (slice). The database computes it for the returned page only.

Sorting and pagination both stay in the database. Sorting by a computed aggregate with limit and offset gives correct pages and the same total on every page.

A computed field always sorts without a database index, because its value is not a stored column. @Sortable on a computed field is always non-indexed.

Filtering on numeric computed fields

Filter values arrive as strings from the query string. Import the validated numberFilter from Numeric and boolean filters into the controller file. It rejects invalid numbers and applies the requested comparison operator. parseFloat alone accepts partial input such as "3devices".

@Computed(
  (row, db) =>
    db
      .table("devices")
      .filter((device) => device.key("group_id").eq(row.key("_id")))
      .count(),
  { default: 0 },
)
@Filter(numberFilter, false)
declare devices_count: number;

@Computed compared with @Joined

Aspect @Computed @Joined
Source Any expression: a computation over the row or a per-row subquery One scalar field from another table matched by a foreign key
Typical use Aggregates (count, sum), derived values Flattening a field of a referenced record
Access Read-only, unless a later @Access replaces it Read-only, unless a later @Access replaces it
Sort and filter In the database, merged only where a step needs the value In the database, join applied before sort and filter

For one field copied from one referenced record, @Joined is simpler. @Computed applies the same merge to any expression.

Driver support

The aggregate form needs a database driver that turns a per-row subquery inside a merge into a correlated lookup. On MongoDB, the lookup is a $lookup with let and pipeline. The @antelopejs/mongodb changelog names the release that added this translation. Row-local expressions use standard expression compilation and work on any driver.

Next steps

For the decorator that copies one field from a referenced record, see Joined fields.