Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Model.$with #45

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
Open
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
32 changes: 32 additions & 0 deletions packages/superflare/docs/database/relationships.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,38 @@ const users = await User.with("profile", "posts").get();

It is particularly important to eager-load related models when passing a model instance to a view.

### Eager Loading By Default

Sometimes you might want to always load some relationships when retrieving a model. To accomplish this, you may define a $with property on the model:

```ts
export class User extends Model {
static $with = ["posts", "profile"];

posts?: Post[] | Promise<Post[]>;
$posts() {
return this.hasMany(Post);
}

profile?: Profile | Promise<Profile>;
$profile() {
return this.hasOne(Profile);
}
}
```

If you would like to remove an item from the $with property for a single query, you may use the without method:

```ts
const user = await User.without("profile").get();
```

If you would like to override all items within the $with property for a single query, you may use the withOnly method:

```ts
const user = await User.withOnly("posts").get();
```

Most front-end frameworks like Remix and Next.js will call `JSON.stringify` on the model instance, but this will **not** load related models automatically.

If you don't eager load the related models, the related data will not be available in your view:
Expand Down
8 changes: 8 additions & 0 deletions packages/superflare/index.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,14 @@ export interface BaseModel<M = any> {
this: T,
relationName: string | string[]
): QueryBuilder<T>;
withOnly<T extends BaseModel>(
this: T,
relationName: string | string[]
): QueryBuilder<T>;
without<T extends BaseModel>(
this: T,
relationName: string | string[]
): QueryBuilder<T>;
create<T extends BaseModel>(
this: T,
attributes: any
Expand Down
32 changes: 32 additions & 0 deletions packages/superflare/src/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ interface Constructor<T> {
export class Model {
static connection = "default";
static table = "";
static $with: string[] = [];
id: number | null;
updatedAt?: Date;
createdAt?: Date;
Expand All @@ -32,6 +33,8 @@ export class Model {
this[key as keyof Model] = attributes[key];
});

validateModel(this);

return new Proxy(this, {
get(target, prop) {
if (prop in target) {
Expand Down Expand Up @@ -128,6 +131,14 @@ export class Model {
return this.query().with(relationName);
}

static withOnly(relationName: string | string[]) {
return this.query().withOnly(relationName);
}

static without(relationName: string | string[]) {
return this.query().without(relationName);
}

static getRelation(relationName: string) {
return new this()[`$${relationName}` as keyof Model]?.();
}
Expand Down Expand Up @@ -316,3 +327,24 @@ export class Model {
}

export interface ModelConstructor<M extends Model> extends Constructor<M> {}

function validateModel(model: Model) {
function checkStaticWithRelationsExist(model: Model) {
if (Object.getOwnPropertyNames(model.constructor).includes("$with")) {
(model.constructor as typeof Model).$with.forEach((relationName) => {
const methodName = `$${relationName}`;
const hasWithRelation = Object.getOwnPropertyNames(
model.constructor.prototype
).includes(methodName);

if (!hasWithRelation) {
throw new Error(
`Relation "${relationName}" does not exist. Please remove "${relationName}" from $with in ${model.constructor.name}.`
);
}
});
}
}

return checkStaticWithRelationsExist(model);
}
28 changes: 28 additions & 0 deletions packages/superflare/src/query-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ export class QueryBuilder {
this.$from =
this.$modelClass.table ||
modelToTableName(sanitizeModuleName(this.$modelClass.name));

if (this.$modelClass.$with) {
this.$eagerLoad.push(...this.$modelClass.$with);
}
}

select(...fields: string[]) {
Expand Down Expand Up @@ -120,6 +124,30 @@ export class QueryBuilder {
return this;
}

withOnly(relationName: string | string[]) {
if (Array.isArray(relationName)) {
this.$eagerLoad = relationName;
} else {
this.$eagerLoad = [relationName];
}

return this;
}

without(relationName: string | string[]) {
if (Array.isArray(relationName)) {
this.$eagerLoad = this.$eagerLoad.filter(
(relation) => !relationName.includes(relation)
);
} else {
this.$eagerLoad = this.$eagerLoad.filter(
(relation) => relation !== relationName
);
}

return this;
}

limit(limit: number) {
this.$limit = limit;
return this;
Expand Down
7 changes: 4 additions & 3 deletions packages/superflare/src/relations/has-many.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export class HasMany extends Relation {
models = models instanceof Array ? models : [models];

return Promise.all(
models.map(async (model) => {
models.map(async (model: any) => {
model[this.foreignKey as keyof Model] =
this.parent[this.ownerKey as keyof Model];
await model.save();
Expand All @@ -27,10 +27,11 @@ export class HasMany extends Relation {
}

create(attributeSets: Record<string, any>[] | Record<string, any>) {
attributeSets = attributeSets instanceof Array ? attributeSets : [attributeSets];
attributeSets =
attributeSets instanceof Array ? attributeSets : [attributeSets];

return Promise.all(
attributeSets.map(async (attributes) => {
attributeSets.map(async (attributes: any) => {
const model = new this.query.modelInstance.constructor(attributes);
model[this.foreignKey as keyof Model] =
this.parent[this.ownerKey as keyof Model];
Expand Down
10 changes: 4 additions & 6 deletions packages/superflare/tests/model/has-many.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ it("saves", async () => {
const posts = await user.$posts().save(
new Post({
text: "Hello World",
}),
})
);

expect(posts[0].userId).toBe(user.id);
Expand All @@ -102,11 +102,9 @@ it("creates", async () => {
const user = await User.create({
name: "John Doe",
});
const posts = await user.$posts().create(
{
text: "Hello World",
},
);
const posts = await user.$posts().create({
text: "Hello World",
});

expect(posts[0].userId).toBe(user.id);
});
Expand Down
179 changes: 179 additions & 0 deletions packages/superflare/tests/model/with.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import { assertType, beforeEach, expect, expectTypeOf, it, test } from "vitest";
import { BaseModel, setConfig } from "../../index.types";
import { Model } from "../../src/model";
import { HasMany } from "../../src/relations/has-many";
import { createTestDatabase } from "../db";

let ModelConstructor = Model as unknown as BaseModel;

class Post extends ModelConstructor {
id!: number;
text!: string;
createdAt!: string;
updatedAt!: string;
userId!: number;
}

class Profile extends ModelConstructor {
id!: number;
text!: string;
userId!: number;
createdAt!: string;
updatedAt!: string;
}

class User extends ModelConstructor {
id!: number;
name!: string;
createdAt!: string;
updatedAt!: string;
profileId?: number;

static $with = ["posts", "profile"];

posts?: Post[] | Promise<Post[]>;
$posts() {
return this.hasMany(Post);
}

profile?: Profile | Promise<Profile>;
$profile() {
return this.hasOne(Profile);
}
}
let database: D1Database;

beforeEach(async () => {
database = await createTestDatabase(`
DROP TABLE IF EXISTS posts;
CREATE TABLE posts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
text TEXT NOT NULL,
userId INTEGER NOT NULL,
createdAt timestamp not null default current_timestamp,
updatedAt timestamp not null default current_timestamp
);

DROP TABLE IF EXISTS profiles;
CREATE TABLE profiles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
text TEXT NOT NULL,
userId INTEGER NOT NULL,
createdAt timestamp not null default current_timestamp,
updatedAt timestamp not null default current_timestamp
);

DROP TABLE IF EXISTS users;
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
createdAt timestamp not null default current_timestamp,
updatedAt timestamp not null default current_timestamp
);
`);
setConfig({
database: {
default: database,
},
});
});

it("works", async () => {
const user = await User.create({
name: "John Doe",
});
await Post.create({
text: "Hello World",
userId: user.id,
});
await Post.create({
text: "Hello again",
userId: user.id,
});

const userWithPosts = await User.find(user.id);

expect(userWithPosts!.posts).toHaveLength(2);
});

it("errors if a relation is passed but doesn't exist", async () => {
class User extends ModelConstructor {
id!: number;
name!: string;
createdAt!: string;
updatedAt!: string;
profileId?: number;

static $with = ["posts"];
}

await expect(
User.create({
name: "John Doe",
})
).rejects.toThrowError(
`Relation "posts" does not exist. Please remove "posts" from $with in User2.`
);
});

test("#withOnly", async () => {
const user = await User.create({
name: "John Doe",
});
await Post.create({
text: "Hello World",
userId: user.id,
});
await Post.create({
text: "Hello again",
userId: user.id,
});
await Profile.create({
text: "Hello World",
userId: user.id,
});

const userWithoutPostsButWithAProfile = await User.withOnly("profile").find(
user.id
);

expect(userWithoutPostsButWithAProfile).toBeTruthy();
expect(userWithoutPostsButWithAProfile!.posts).toBeInstanceOf(HasMany);

expect(userWithoutPostsButWithAProfile!.profile).toBeInstanceOf(Profile);
expect((userWithoutPostsButWithAProfile!.profile as Profile).id).toBe(1);
expect((userWithoutPostsButWithAProfile!.profile as Profile).text).toBe(
"Hello World"
);
});

test("#without", async () => {
const user = await User.create({
name: "John Doe",
});
await Post.create({
text: "Hello World",
userId: user.id,
});
await Post.create({
text: "Hello again",
userId: user.id,
});
await Profile.create({
text: "Hello World",
userId: user.id,
});

const userWithoutPostsButWithAProfile = await User.without("posts").find(
user.id
);

expect(userWithoutPostsButWithAProfile).toBeTruthy();
expect(userWithoutPostsButWithAProfile!.posts).toBeInstanceOf(HasMany);

expect(userWithoutPostsButWithAProfile!.profile).toBeInstanceOf(Profile);
expect((userWithoutPostsButWithAProfile!.profile as Profile).id).toBe(1);
expect((userWithoutPostsButWithAProfile!.profile as Profile).text).toBe(
"Hello World"
);
});