Skip to content

Commit

Permalink
feat(objection): add model relationship decorators
Browse files Browse the repository at this point in the history
  • Loading branch information
Ross MacPhee committed Sep 5, 2022
1 parent e907869 commit e650b24
Show file tree
Hide file tree
Showing 19 changed files with 604 additions and 2 deletions.
77 changes: 77 additions & 0 deletions docs/tutorials/objection.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,83 @@ export class User extends Model {
}
```

## Relationships

Ts.ED enables you to define relationships between models on properties directly, using decorators such as @@BelongsToOne@@, @@HasMany@@, @@HasOne@@, @@HasOneThroughRelationship@@, @@ManyToMany@@ or @@RelatesTo@@.

You can supply a configuration object via (@@RelationshipOpts@@) into the decorator factor to override the default join keys and configure a relationship like you normally would via `relationMappings`.

This expressive usage ensures that your domain models are correctly typed for usage alongside [Objection.js's Graph API](https://vincit.github.io/objection.js/api/query-builder/eager-methods.html).

```typescript
/**
* All work in a similar manner:
* - @HasMany, @HasOne, @HasOneThroughRelation, @ManyToMany, @RelatesTo
*/
import {Entity, BelongsToOne} from "@tsed/objection";

@Entity("user")
class User extends Model {
@IdColumn()
id!: string;
}

@Entity("movie")
class Movie extends Model {
@IdColumn()
id!: string;

ownerId!: string;

@BelongsToOne()
owner?: User;
}

// Retrieve the related user
const owner = await Movie.relatedQuery("owner").for(1);

// Retrieve the movie with their owner
const movie = await Movie.query().for(1).withGraphFetched("owner");
```

### Default joining keys

When used in conjunction with @@Entity@@ and @@IdColumn@@, Ts.ED attempts to provide you with a sensible default for your join keys out of the box, reducing the amount of boilerplate you need to write.

In the instance of @@BelongsToOne@@, the default join keys will be:

```json
// (e.g.: `movie.ownerId` and `user.id`)
{
"from": "<sourceModelTable>.<foreignModelProperty>Id",
"to": "<foreignModelTable>.<foreignModelIdColumn>"
}
```

In the instances of @@HasMany@@ and @@HasOne@@, the default join keys will be:

```json
// (e.g.: `user.id` and `authentication.userId`)
{
"from": "<sourceModelTable>.<sourceModelIdColumn>",
"to": "<foreignModelTable>.<sourceModelTable>Id"
}
```

In the instances of @@ManyToMany@@ and @@HasOneThroughRelation@@, the default join key will be:

```json
// (e.g.: `user.id`, `user_authentication.userId`, `user_authentication.authenticationId` `authentication.id`)
{
"from": "<sourceModelTable>.<sourceModelIdColumn>",
"through": {
"from": "<sourceModelTable>_<foreignModelTable>.<sourceModelTable>Id",
"to": "<sourceModelTable>_<foreignModelTable>.<foreignModelTable>Id"
},
"to": "<foreignModelTable>.<foreignModelIdColumn>"
}
```

## Get connection

```typescript
Expand Down
58 changes: 58 additions & 0 deletions packages/orm/objection/src/decorators/belongsToOne.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import {Entity, IdColumn} from "../../lib/esm";

import {BelongsToOne} from "@tsed/objection";
import {Model} from "objection";

describe("@BelongsToOne", () => {
it("should set metadata", () => {
@Entity("user")
class User extends Model {
@IdColumn()
id!: string;
}
@Entity("movie")
class Movie extends Model {
@IdColumn()
id!: string;
userId!: string;
@BelongsToOne()
user?: User;
}
expect(Movie.relationMappings).toEqual({
user: {
relation: Model.BelongsToOneRelation,
modelClass: User,
join: {
from: "movie.userId",
to: "user.id"
}
}
});
});
it("should set custom relationship path", () => {
@Entity("user")
class User extends Model {
@IdColumn()
id!: string;
userId!: string;
}
@Entity("movie")
class Movie extends Model {
@IdColumn()
id!: string;
ownerId!: string;
@BelongsToOne({from: "ownerId", to: "userId"})
user?: User;
}
expect(Movie.relationMappings).toEqual({
user: {
relation: Model.BelongsToOneRelation,
modelClass: User,
join: {
from: "movie.ownerId",
to: "user.userId"
}
}
});
});
});
13 changes: 13 additions & 0 deletions packages/orm/objection/src/decorators/belongsToOne.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import {Model} from "objection";
import {RelatesTo} from "./relatesTo";
import {RelationshipOptsWithoutThrough} from "../domain/RelationshipOpts";

/**
*
* @param opts
* @decorator
* @objection
*/
export function BelongsToOne(opts?: RelationshipOptsWithoutThrough): PropertyDecorator {
return RelatesTo(Model.BelongsToOneRelation, opts);
}
6 changes: 4 additions & 2 deletions packages/orm/objection/src/decorators/entity.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {Type} from "@tsed/core";
import {getJsonSchema} from "@tsed/schema";
import {defineStaticGetter} from "../utils/defineStaticGetter";
import {getJsonEntityRelationships} from "../utils/getJsonEntityRelationships";
import {getJsonSchema} from "@tsed/schema";

/**
*
Expand All @@ -14,9 +15,10 @@ export function Entity(tableName: string): ClassDecorator {
}

return (target: any) => {
const originalRelationMappings = target["relationMappings"];
defineStaticGetter(target, "tableName", () => tableName);
defineStaticGetter(target, "jsonSchema", () => getJsonSchema(target));

defineStaticGetter(target, "relationMappings", () => originalRelationMappings || getJsonEntityRelationships(target));
return target;
};
}
Expand Down
58 changes: 58 additions & 0 deletions packages/orm/objection/src/decorators/hasMany.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import {Entity, HasMany} from "@tsed/objection";

import {IdColumn} from "../../lib/esm";
import {Model} from "objection";

describe("@HasMany", () => {
it("should set metadata", () => {
@Entity("pet")
class Pet extends Model {
@IdColumn()
id!: string;
userId?: string;
}
@Entity("user")
class User extends Model {
@IdColumn()
id!: string;
@HasMany()
pets?: Pet[];
}
expect(User.relationMappings).toEqual({
pets: {
relation: Model.HasManyRelation,
modelClass: Pet,
join: {
from: "user.id",
to: "pet.userId"
}
}
});
});
it("should set custom relationship path", () => {
@Entity("pet")
class Pet extends Model {
@IdColumn()
id!: string;
ownerId?: string;
}
@Entity("user")
class User extends Model {
@IdColumn()
id!: string;
userId!: string;
@HasMany({from: "userId", to: "ownerId"})
pets?: Pet[];
}
expect(User.relationMappings).toEqual({
pets: {
relation: Model.HasManyRelation,
modelClass: Pet,
join: {
from: "user.userId",
to: "pet.ownerId"
}
}
});
});
});
13 changes: 13 additions & 0 deletions packages/orm/objection/src/decorators/hasMany.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import {Model} from "objection";
import {RelatesTo} from "./relatesTo";
import {RelationshipOptsWithoutThrough} from "../domain/RelationshipOpts";

/**
*
* @param opts
* @decorator
* @objection
*/
export function HasMany(opts?: RelationshipOptsWithoutThrough): PropertyDecorator {
return RelatesTo(Model.HasManyRelation, opts);
}
58 changes: 58 additions & 0 deletions packages/orm/objection/src/decorators/hasOne.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import {Entity, HasOne} from "@tsed/objection";

import {IdColumn} from "../../lib/esm";
import {Model} from "objection";

describe("@HasOne", () => {
it("should set metadata", () => {
@Entity("pet")
class Pet extends Model {
@IdColumn()
id!: string;
userId?: string;
}
@Entity("user")
class User extends Model {
@IdColumn()
id!: string;
@HasOne()
pet?: Pet;
}
expect(User.relationMappings).toEqual({
pet: {
relation: Model.HasOneRelation,
modelClass: Pet,
join: {
from: "user.id",
to: "pet.userId"
}
}
});
});
it("should set custom relationship path", () => {
@Entity("pet")
class Pet extends Model {
@IdColumn()
id!: string;
ownerId?: string;
}
@Entity("user")
class User extends Model {
@IdColumn()
id!: string;
userId!: string;
@HasOne({from: "userId", to: "ownerId"})
pet?: Pet;
}
expect(User.relationMappings).toEqual({
pet: {
relation: Model.HasOneRelation,
modelClass: Pet,
join: {
from: "user.userId",
to: "pet.ownerId"
}
}
});
});
});
13 changes: 13 additions & 0 deletions packages/orm/objection/src/decorators/hasOne.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import {Model} from "objection";
import {RelatesTo} from "./relatesTo";
import {RelationshipOptsWithoutThrough} from "../domain/RelationshipOpts";

/**
*
* @param opts
* @decorator
* @objection
*/
export function HasOne(opts?: RelationshipOptsWithoutThrough): PropertyDecorator {
return RelatesTo(Model.HasOneRelation, opts);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import {Entity, HasOneThroughRelation} from "@tsed/objection";

import {IdColumn} from "../../lib/esm";
import {Model} from "objection";

describe("@HasOneThroughRelation", () => {
it("should set metadata", () => {
@Entity("pet")
class Pet extends Model {
@IdColumn()
id!: string;
userId?: string;
}
@Entity("user")
class User extends Model {
@IdColumn()
id!: string;
@HasOneThroughRelation()
pet?: Pet;
}
expect(User.relationMappings).toEqual({
pet: {
relation: Model.HasOneThroughRelation,
modelClass: Pet,
join: {
from: "user.id",
through: {
from: "user_pet.userId",
to: "user_pet.petId"
},
to: "pet.id"
}
}
});
});
it("should set custom relationship path", () => {
@Entity("pet")
class Pet extends Model {
@IdColumn()
id!: string;
petId?: string;
}
@Entity("user")
class User extends Model {
@IdColumn()
id!: string;
userId!: string;
@HasOneThroughRelation({
from: "userId",
through: {from: "user_pet.ownerId", to: "user_pet.petId"},
to: "petId"
})
pet?: Pet;
}
expect(User.relationMappings).toEqual({
pet: {
relation: Model.HasOneThroughRelation,
modelClass: Pet,
join: {
from: "user.userId",
through: {
from: "user_pet.ownerId",
to: "user_pet.petId"
},
to: "pet.petId"
}
}
});
});
});
Loading

0 comments on commit e650b24

Please sign in to comment.