| title | Foreign keys |
|---|---|
| description | Resolve a foreign key field into the referenced record with @Foreign, for single and array references, across schemas, and per route. |
| mode | reference |
The @Foreign decorator links a field of your data controller to another table. When a field holds a foreign key (an ID of a record in another table), the Data API looks up the referenced record and puts it in the response. The field can hold one key or an array of keys.
Apply @Foreign to a field that stores a foreign key value. The response then holds the referenced record in place of the raw ID.
Choose the response fields with pluck. A foreign lookup reads the target table, not its API controller, so the target controller's @Access policy does not hide passwords or internal fields. The example below selects only the user's ID, name, and email.
The snippets use the myapp schema. Register it in the declaring module before serving requests, as in the quick start. The controller snippets assume that the matching model and the shared imports exist.
import { Foreign } from "@antelopejs/interface-data-api/metadata";
import {
Field,
RegisterTable,
Relation,
Table,
} from "@antelopejs/interface-database-decorators";
@RegisterTable("users", "myapp")
class User extends Table {
@Field("string")
declare _id: string;
@Field("string")
declare name: string;
@Field("string")
declare email: string;
}
@RegisterTable("orders", "myapp")
class Order extends Table {
@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;
@Listable()
@Access(AccessMode.ReadOnly)
@Foreign(User, undefined, false, ["_id", "name", "email"])
declare userId: User;
@Listable()
@Access(AccessMode.ReadOnly)
declare total: number;
}A GET request to /orders/get?id=order-123 returns the user object in place of the raw ID:
{
"_id": "order-123",
"userId": {
"_id": "user-456",
"name": "Bob",
"email": "bob@example.com"
},
"total": 99.99
}@Foreign(table, index?, multi?, pluck?, schema?, targetMeta?)| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
table |
Class<Table> or string |
Yes | None | The target table class or table name string |
index |
string |
No | "_id" |
The index on the target table to match against |
multi |
boolean |
No | false |
Set to true when the field holds an array of foreign keys |
pluck |
string[] |
No | All fields | List of fields to include from the referenced record |
schema |
string |
No | Controller schema | Name of the schema the target table is registered in, when it differs from the controller's schema |
targetMeta |
DataAPIMeta |
No | None | Metadata of the target's data controller. Its @Joined fields that pluck names, or all of them when pluck is unset, are resolved on a relation that is not multi |
Pass the table class. Your editor can then complete and rename the reference, and the compiler checks it.
@Foreign(User)
declare userId: User;Pass the registered table name as a string. Use a string when importing the class would create a circular import.
@Foreign("users")
declare userId: User;When a field stores an array of foreign key IDs, set the multi parameter to true. The API looks up each ID and returns an array of records.
The table-level @Relation({ many: true }) is metadata only. It records that the field holds several target keys. Only the multi parameter of @Foreign controls how the API resolves the field.
@RegisterTable("carts", "myapp")
class Cart extends Table {
@Field("string")
declare _id: string;
@Field(["string"])
@Relation({ to: () => CartItem, many: true })
declare items: string[]; // Array of CartItem IDs
}
@RegisterTable("cart_items", "myapp")
class CartItem extends Table {
@Field("string")
declare _id: string;
@Field("string")
declare name: string;
@Field("number")
declare price: number;
}
@RegisterDataController()
class CartAPI extends DataController(
Cart,
DefaultRoutes.All,
Controller("/carts"),
) {
@ModelReference()
@Model(CartModel, "my-database")
declare cartModel: CartModel;
@Listable()
@Access(AccessMode.ReadOnly)
declare _id: string;
@Listable()
@Access(AccessMode.ReadOnly)
@Foreign(CartItem, undefined, true)
declare items: CartItem[];
}Response:
{
"_id": "cart-123",
"items": [
{ "_id": "item-1", "name": "Widget", "price": 9.99 },
{ "_id": "item-2", "name": "Gadget", "price": 24.99 }
]
}The pluck parameter limits the fields taken from the referenced record. Use it to keep the response small when you need only some fields.
@RegisterDataController()
class CartAPI extends DataController(
Cart,
DefaultRoutes.All,
Controller("/carts"),
) {
@ModelReference()
@Model(CartModel, "my-database")
declare cartModel: CartModel;
@Listable()
@Access(AccessMode.ReadOnly)
declare _id: string;
// Only include name and email from User
@Access(AccessMode.ReadOnly)
@Foreign(User, undefined, false, ["name", "email"])
declare userId: User;
// Only include name and price from CartItem
@Access(AccessMode.ReadOnly)
@Foreign(CartItem, undefined, true, ["name", "price"])
declare items: CartItem[];
}Response without pluck:
{
"_id": "cart-123",
"userId": {
"_id": "user-456",
"name": "Bob",
"email": "bob@example.com",
"password": "hashed_value",
"internalNotes": "..."
}
}Response with pluck: ["name", "email"]:
{
"_id": "cart-123",
"userId": {
"name": "Bob",
"email": "bob@example.com"
}
}The index parameter matches the field against a secondary index of the target table, in place of the primary key.
// Match userEmail against the "email" index on the User table
@Foreign(User, "email")
declare userEmail: User;By default, the Data API looks for the target table in the schema of the controller's table. The schema parameter names a different schema. At runtime, the foreign lookup goes through the target's table class, against the default instance of the schema that RegisterSchema bound it to. The lookup therefore uses the database of the module that declared the table. Register the target's class with @RegisterTable before you declare the field. A string table name with no registered class in that schema throws at declaration.
// Resolve userId against the User table registered in the "auth" schema
@Access(AccessMode.ReadOnly)
@Foreign(User, undefined, false, undefined, "auth")
declare userId: User;When you pass a table class, the Data API reads its schema from its @RegisterTable metadata. The schema parameter is then needed only with a string table name, or when the class metadata names no schema. If the class metadata and the schema parameter name different schemas, the declaration throws.
The noForeign route option skips foreign key resolution on one route. The route then returns raw IDs in place of records. On a get route the option is a string, and on a list route it is a boolean.
import { DefaultRoutes } from "@antelopejs/interface-data-api";
const routes = {
get: DefaultRoutes.Get,
getRaw: DefaultRoutes.WithOptions(DefaultRoutes.Get, { noForeign: "true" }),
};- If a referenced record does not exist, the field value is
null. - An invalid foreign key value resolves to
null. - In a multi-record reference, each missing item is
nullin the array.
To filter list results, see Filters.