The @Joined decorator imports a single scalar field from another table and flattens it onto each row at query time. Unlike @Foreign, which embeds the full referenced record as a nested object in the response, @Joined copies one remote field to the top level of the row before sorting and filtering are applied. This makes the joined field natively sortable, filterable, and searchable in the database.
Joined fields are read-only: the value is set by a lookup at query time and is never persisted on the controller's own table. Declaring a field as joined automatically sets its access mode to read-only and forces non-indexed sorting.
Apply @Joined to a field that should be populated from a remote table. The local row must carry a foreign key (localKey) that matches the remote table's remoteIndex.
import { Joined } from "@antelopejs/interface-data-api/metadata";
import {
Field,
Index,
RegisterTable,
Relation,
Table,
} from "@antelopejs/interface-database-decorators";
@RegisterTable("users")
class User extends Table {
@Index({ primary: true })
@Field("string")
declare _id: string;
@Field("string")
declare name: string;
}
@RegisterTable("orders")
class Order extends Table {
@Index({ primary: true })
@Field("string")
declare _id: string;
@Field("string")
@Relation({ to: () => User })
declare userId: string;
@Field("number")
declare total: number;
}
@RegisterDataController()
class OrderAPI extends DataController(
Order,
DefaultRoutes.All,
Controller("/orders"),
) {
@ModelReference()
@Model(OrderModel, "my-database")
declare orderModel: OrderModel;
@Listable()
@Access(AccessMode.ReadOnly)
declare _id: string;
declare userId: string;
// Pull the remote "name" field from the User referenced by userId
@Listable()
@Joined({
table: User,
localKey: "userId",
remoteField: "name",
})
declare userName: string;
@Listable()
@Access(AccessMode.ReadOnly)
declare total: number;
}A GET request to /orders/get?id=order-123 returns the joined value at the top level:
{
"_id": "order-123",
"userName": "Bob",
"total": 99.99
}@Joined({ table, localKey, remoteField, remoteIndex?, schema? })| Option | Type | Required | Default | Description |
|---|---|---|---|---|
table |
Class<Table> or string |
Yes | - | The remote table class or registered table name string |
localKey |
string |
Yes | - | The field on the local row holding the value matched against the remote table |
remoteField |
string |
Yes | - | The remote field whose value is flattened onto the row |
remoteIndex |
string |
No | "_id" |
The index on the remote table to match localKey against |
schema |
string |
No | Controller schema | Name of the schema the remote table is registered in, when it differs from the controller's schema |
Because the join is applied to the underlying query before sorting and filtering, joined fields behave like native columns. Combine @Joined with @Sortable or @Filter to order or filter by the imported value.
@Sortable()
@Filter()
@Joined({
table: User,
localKey: "userId",
remoteField: "name",
})
declare userName: string;Joined fields are always sorted without a database index (@Sortable is forced to non-indexed for them), since the value comes from a runtime lookup rather than a stored column.
Query.List only materializes the joined groups that the request's sort or filters reference before the count. The remaining display-only groups are deferred: the returned stream resolves them itself as soon as an operation can observe them, so destructuring [query, total] alone always yields rows with every @Joined field resolved.
- Paging the stream (
slice,nth) appends the lookups after the page boundary, so they only run on the returned rows. - A bare
count()— and any field aggregate (sum,min,distinct(field), ...) on a non-joined field — skips them entirely (left-join lookups never change the row count nor other fields). changes()attaches to the raw stream, since change feeds cannot carry the join lookups.- Any other operation (
filter,orderBy,map, ...) materializes them first, since it may read the joined values.
The page-only benefit therefore holds as long as paging is the first thing the caller does. An operation applied to the whole stream before paging — a filter, a sort, or the lookup that Query.Foreign stages for a @Foreign field — materializes the joined groups over the whole matched set instead. The lookups still stay out of the count pipeline, but a caller that wants them strictly on the page must pass { exposeDeferredJoined: true } to Query.List and resolve them itself after the slice: the raw stream is then returned unchanged and the third tuple element names the joined fields left to resolve with Query.Joined. This is what the default list route does, which also lets it limit the page lookups to the plucked fields.
By default, the remote table is resolved in the same schema as the controller's table. Use the schema option to join against a table registered in a different schema; the join is resolved against that schema's default instance.
@Joined({
table: "users",
localKey: "userId",
remoteField: "name",
schema: "auth",
})
declare userName: string;When a table class is passed, its schema is inferred from its @RegisterTable metadata. If both the class metadata and the explicit schema option specify a schema and they disagree, an error is thrown.
| Aspect | @Joined |
@Foreign |
|---|---|---|
| Result shape | A single scalar value flattened onto the row | The full referenced record (or plucked subset) nested under the field |
| Access | Read-only (enforced) | Configurable via @Access |
| Sort / filter | Native (join applied before sort/filter) | Not natively sortable/filterable on remote fields |
| Multiple references | One remote field per decorator | Single or array (multi) references |
See the computed fields documentation to generalize this merge mechanism to arbitrary in-database expressions, or the modifiers documentation to learn how automatic data transformation integrates with the Data API.