diff --git a/docs/2.table-definitions.md b/docs/2.table-definitions.md index 9a56a04..a589be4 100644 --- a/docs/2.table-definitions.md +++ b/docs/2.table-definitions.md @@ -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. diff --git a/package.json b/package.json index 757f13c..e6636f7 100644 --- a/package.json +++ b/package.json @@ -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" @@ -112,6 +116,9 @@ "model": [ "dist/model.d.ts" ], + "relation": [ + "dist/relation.d.ts" + ], "schema": [ "dist/schema.d.ts" ], diff --git a/src/index.ts b/src/index.ts index 48fc1c8..ef4a9de 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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"; diff --git a/src/relation.ts b/src/relation.ts new file mode 100644 index 0000000..ec5604b --- /dev/null +++ b/src/relation.ts @@ -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 = {}; +} + +/** + * 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; + }, +); diff --git a/src/tests/relation.test.ts b/src/tests/relation.test.ts new file mode 100644 index 0000000..1d539a6 --- /dev/null +++ b/src/tests/relation.test.ts @@ -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"); +}