Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions docs/2.table-definitions.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,35 @@ class Order extends Table {

Properties without `@Field` are omitted from `fields`. `@Field` stacks freely with `@Index`.

## The Relation Decorator

The `Relation` decorator declares a link from a field to another table. It is **declarative metadata only**: database implementations do not enforce it (no foreign key constraint, no referential validation). Introspection tooling consumes it to expose the links between tables β€” for example to render schema diagrams or navigate related records.

```typescript
import { Table, Field, Index, Relation } from "@antelopejs/interface-database-decorators";

class Comment extends Table {
@Index()
@Field("string")
@Relation({ to: () => Post })
declare postId: string;

@Field("string")
@Relation({ to: () => Tag, many: true })
declare tagIds: string[];
}
```

### Options

| Option | Type | Description |
| --------- | ------------------- | ---------------------------------------------------------------------- |
| `to` | `() => typeof Table` | Thunk returning the target table class (lazy to allow forward references). |
| `toField` | `string` | Target field name. Defaults to the target table's primary key. |
| `many` | `boolean` | The decorated field holds multiple target keys (many targets per source record). |

`@Relation` stacks freely with `@Field` and `@Index`.

## The RegisterTable Decorator

The `RegisterTable` class decorator associates a table class with a specific table name and schema. This registration is used by `RegisterSchema` to build the schema definition automatically.
Expand Down
7 changes: 7 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@
"types": "./dist/model.d.ts",
"default": "./dist/model.js"
},
"./relation": {
"types": "./dist/relation.d.ts",
"default": "./dist/relation.js"
},
"./schema": {
"types": "./dist/schema.d.ts",
"default": "./dist/schema.js"
Expand Down Expand Up @@ -112,6 +116,9 @@
"model": [
"dist/model.d.ts"
],
"relation": [
"dist/relation.d.ts"
],
"schema": [
"dist/schema.d.ts"
],
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@ export * from "./common";
export * from "./database";
export * from "./model";
export * from "./modifiers";
export * from "./relation";
export * from "./schema";
export * from "./table";
53 changes: 53 additions & 0 deletions src/relation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { MakePropertyDecorator } from "@antelopejs/interface-core/decorators";
import { getMetadata } from "./common";
import type { Table } from "./table";

/**
* Options for the {@link Relation} decorator.
*/
export interface RelationOptions {
/**
* Thunk returning the target Table class (lazy to allow forward references).
*/
to: () => typeof Table;
/**
* Field on the target table the relation points to. Defaults to the target
* table's primary key.
*/
toField?: string;
/**
* The decorated field holds multiple target keys (many targets per source
* record).
*/
many?: boolean;
}

/**
* Relation Metadata.
*/
export class RelationStaticMetadata {
public static key = Symbol();

public readonly relations: Record<string, RelationOptions> = {};
}

/**
* Database Table Relation decorator.
*
* Declarative metadata only: database implementations do not enforce it (no
* foreign key constraint, no referential validation). It is consumed by
* introspection tooling to expose the links between tables.
*
* Available options:
* - `to`: Thunk returning the target Table class.
* - `toField`: Target field name (defaults to the target's primary key).
* - `many`: Whether the field holds multiple target keys.
*
* @param options Options
*/
export const Relation = MakePropertyDecorator(
(target, propertyKey, options: RelationOptions) => {
const metadata = getMetadata(target.constructor, RelationStaticMetadata);
metadata.relations[String(propertyKey)] = options;
},
);
109 changes: 109 additions & 0 deletions src/tests/relation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { getMetadata } from "@antelopejs/interface-database-decorators/common";
import {
Relation,
RelationStaticMetadata,
} from "@antelopejs/interface-database-decorators/relation";
import { Table } from "@antelopejs/interface-database-decorators/table";
import { expect } from "chai";

describe("Relation - Relation decorator", () => {
it("stores relation options in metadata", async () =>
StoreRelationOptionsInMetadataTest());
it("stores toField and many options", async () =>
StoreToFieldAndManyOptionsTest());
it("supports multiple relations on one class", async () =>
SupportMultipleRelationsOnOneClassTest());
it("resolves forward references through the thunk", async () =>
ResolveForwardReferencesThroughThunkTest());
it("keeps metadata separate between classes", async () =>
KeepMetadataSeparateBetweenClassesTest());
});

async function StoreRelationOptionsInMetadataTest() {
class TargetTable extends Table {
name!: string;
}

class SourceTable extends Table {
@Relation({ to: () => TargetTable })
target!: string;
}

const metadata = getMetadata(SourceTable, RelationStaticMetadata);
expect(metadata.relations).to.have.property("target");
expect(metadata.relations.target.to()).to.equal(TargetTable);
expect(metadata.relations.target.toField).to.equal(undefined);
expect(metadata.relations.target.many).to.equal(undefined);
}

async function StoreToFieldAndManyOptionsTest() {
class TargetTable extends Table {
code!: string;
}

class SourceTable extends Table {
@Relation({ to: () => TargetTable, toField: "code", many: true })
targets!: string[];
}

const metadata = getMetadata(SourceTable, RelationStaticMetadata);
expect(metadata.relations.targets.toField).to.equal("code");
expect(metadata.relations.targets.many).to.equal(true);
}

async function SupportMultipleRelationsOnOneClassTest() {
class UserTable extends Table {
name!: string;
}

class RoleTable extends Table {
label!: string;
}

class SourceTable extends Table {
@Relation({ to: () => UserTable })
owner!: string;

@Relation({ to: () => RoleTable, many: true })
roles!: string[];
}

const metadata = getMetadata(SourceTable, RelationStaticMetadata);
expect(Object.keys(metadata.relations)).to.have.members(["owner", "roles"]);
expect(metadata.relations.owner.to()).to.equal(UserTable);
expect(metadata.relations.roles.to()).to.equal(RoleTable);
}

async function ResolveForwardReferencesThroughThunkTest() {
class SourceTable extends Table {
@Relation({ to: () => LaterTable })
later!: string;
}

class LaterTable extends Table {
name!: string;
}

const metadata = getMetadata(SourceTable, RelationStaticMetadata);
expect(metadata.relations.later.to()).to.equal(LaterTable);
}

async function KeepMetadataSeparateBetweenClassesTest() {
class TargetTable extends Table {
name!: string;
}

class FirstTable extends Table {
@Relation({ to: () => TargetTable })
target!: string;
}

class SecondTable extends Table {
other!: string;
}

const firstMetadata = getMetadata(FirstTable, RelationStaticMetadata);
const secondMetadata = getMetadata(SecondTable, RelationStaticMetadata);
expect(firstMetadata.relations).to.have.property("target");
expect(secondMetadata.relations).to.not.have.property("target");
}
Loading